Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ and this project adheres to Semantic Versioning (https://semver.org/spec/v2.0.0.

- Added typed part metadata with `builtin.Part(...)` and `Component(part=...)`, including JSON netlist serialization and `list[Part]` support for `properties["alternatives"]`.

### Fixed

- Apply MVS-selected KiCad asset versions in resolution/materialization and sibling promotion, preventing `@kicad-*` alias failures after patch updates.
- `pcb update` now ignores prerelease dependency versions when selecting updates.

## [0.3.50] - 2026-03-02

### Changed
Expand Down
70 changes: 67 additions & 3 deletions crates/pcb-zen-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,67 @@ pub struct PcbToml {
pub access: Option<AccessConfig>,
}

fn asset_targets_repo(asset_key: &str, repo: &str) -> bool {
asset_key == repo
|| (asset_key.starts_with(repo) && asset_key.as_bytes().get(repo.len()) == Some(&b'/'))
}

fn parse_asset_semver(spec: &AssetDependencySpec) -> Option<Version> {
let raw = match spec {
AssetDependencySpec::Ref(v) => Some(v.as_str()),
AssetDependencySpec::Detailed(d) => d.version.as_deref(),
}?;
let raw = raw.strip_prefix('v').unwrap_or(raw);
Version::parse(raw).ok()
}

impl PcbToml {
fn add_implicit_legacy_asset_dependencies(&mut self) {
if self.assets.is_empty() {
return;
}

let entries = self
.workspace
.as_ref()
.map(|w| w.kicad_library.clone())
.unwrap_or_else(default_kicad_library);
let repos: Vec<&String> = entries
.iter()
.flat_map(|entry| {
std::iter::once(&entry.symbols)
.chain(std::iter::once(&entry.footprints))
.chain(entry.models.values())
})
.collect();

let mut selected = BTreeMap::<String, Version>::new();
for (asset_key, spec) in &self.assets {
let Some(version) = parse_asset_semver(spec) else {
continue;
};

for repo in &repos {
if !asset_targets_repo(asset_key, repo) {
continue;
}
let should_update = match selected.get(repo.as_str()) {
Some(cur) => version > *cur,
None => true,
};
if should_update {
selected.insert((*repo).clone(), version.clone());
}
}
}

for (repo, version) in selected {
self.dependencies
.entry(repo)
.or_insert_with(|| DependencySpec::Version(version.to_string()));
}
}

/// Check if this uses legacy V1-only constructs.
fn requires_v1(&self) -> bool {
!self.packages.is_empty() || self.module.is_some()
Expand Down Expand Up @@ -99,7 +159,9 @@ impl PcbToml {

/// Parse from TOML string
pub fn parse(content: &str) -> Result<Self> {
toml::from_str(content).map_err(|e| anyhow::anyhow!("{e}"))
let mut parsed: Self = toml::from_str(content).map_err(|e| anyhow::anyhow!("{e}"))?;
parsed.add_implicit_legacy_asset_dependencies();
Ok(parsed)
}

/// Parse from file, rendering errors with ariadne-style diagnostics
Expand All @@ -120,7 +182,7 @@ impl PcbToml {

/// Parse TOML content with path context for error reporting
pub fn parse_with_path(content: &str, path: &Path) -> Result<Self> {
toml::from_str(content).map_err(|e| {
let mut parsed: Self = toml::from_str(content).map_err(|e| {
if let Some(span) = e.span() {
let path_str = path.display().to_string();
let mut buf = Vec::new();
Expand All @@ -140,7 +202,9 @@ impl PcbToml {
} else {
anyhow::anyhow!("failed to parse {}: {e}", path.display())
}
})
})?;
parsed.add_implicit_legacy_asset_dependencies();
Ok(parsed)
}

/// Extract and parse inline pcb.toml from .zen file content
Expand Down
26 changes: 15 additions & 11 deletions crates/pcb-zen-core/src/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,26 +102,30 @@ fn resolve_package_deps<R: PackagePathResolver>(
}
}

// If any repo from a [[workspace.kicad_library]] entry is referenced (via
// [dependencies] or [assets]), resolve all sibling repos from that entry.
// If any repo from a [[workspace.kicad_library]] entry is referenced via
// dependencies, resolve all sibling repos from that entry.
// e.g. adding kicad-symbols as a dep also brings in kicad-footprints + models.
for entry in workspace.kicad_library_entries() {
let repos: Vec<&String> = [&entry.symbols, &entry.footprints]
.into_iter()
.chain(entry.models.values())
.collect();
let has_reference = repos.iter().any(|repo| {
let prefix = format!("{repo}/");
map.contains_key(repo.as_str())
|| config
.assets
.keys()
.any(|a| a.starts_with(&prefix) || a == repo.as_str())
});
let has_reference = repos.iter().any(|repo| map.contains_key(repo.as_str()));
if !has_reference {
continue;
}
let version_str = entry.version.to_string();
// Use the version from an already-resolved sibling repo path so this
// stays aligned with selected/MVS resolution.
let Some(version_str) = repos.iter().find_map(|repo| {
map.get(*repo).and_then(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
})
}) else {
// No resolved sibling to anchor version selection; skip promotion.
continue;
};
for repo in repos {
if !map.contains_key(repo.as_str())
&& let Some(path) = resolver.resolve_package(repo, &version_str)
Expand Down
78 changes: 71 additions & 7 deletions crates/pcb-zen/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,17 @@ pub fn resolve_dependencies(

log::debug!("Phase 2: Build closure");

let selected_kicad_assets: BTreeMap<String, Version> = selected
.iter()
.filter(|(line, version)| {
matches!(
match_kicad_managed_repo(&kicad_entries, &line.path, version),
KicadRepoMatch::SelectorMatched
)
})
.map(|(line, version)| (line.path.clone(), version.clone()))
.collect();

// Phase 2: Build the final dependency set using only selected versions
// Path-patched forks are now workspace members, so their deps are included automatically
let closure = build_closure(&workspace_info.packages, &selected, &manifest_cache);
Expand All @@ -803,7 +814,7 @@ pub fn resolve_dependencies(

// Phase 2.5: Materialize asset dependencies (KiCad symbol/footprint/model repos).
log::debug!("Phase 2.5: Materialize asset dependencies");
materialize_asset_deps(workspace_info, offline)?;
materialize_asset_deps(workspace_info, &selected_kicad_assets, offline)?;
log::debug!("Materialized asset dependencies");

// Phase 3: (Removed - sparse checkout and hashing now done in Phase 1)
Expand Down Expand Up @@ -865,7 +876,8 @@ pub fn resolve_dependencies(

log::debug!("dependency resolution complete");

let package_resolutions = build_native_resolution_map(workspace_info, &closure, &patches);
let package_resolutions =
build_native_resolution_map(workspace_info, &closure, &selected_kicad_assets, &patches);

Ok(ResolutionResult {
workspace_info: workspace_info.clone(),
Expand Down Expand Up @@ -1088,6 +1100,7 @@ fn prune_dir(
fn build_native_resolution_map(
workspace_info: &WorkspaceInfo,
closure: &HashMap<ModuleLine, Version>,
selected_kicad_assets: &BTreeMap<String, Version>,
patches: &BTreeMap<String, PatchSpec>,
) -> HashMap<PathBuf, BTreeMap<String, PathBuf>> {
// Use workspace cache path (symlink) for stable workspace-relative paths in generated files
Expand Down Expand Up @@ -1131,6 +1144,15 @@ fn build_native_resolution_map(
.insert(line.family.clone(), abs_path);
}
}
for (repo, version) in selected_kicad_assets {
let version_str = version.to_string();
if let Some(abs_path) = base_resolver.resolve_package(repo, &version_str) {
families
.entry(repo.clone())
.or_default()
.insert(semver_family(version), abs_path);
}
}

let resolver = MvsFamilyResolver {
families,
Expand Down Expand Up @@ -1296,14 +1318,17 @@ fn pseudo_matches_rev(version: &Version, rev: &str) -> bool {
}

/// Materialize asset dependencies selected by dependency resolution.
fn materialize_asset_deps(workspace_info: &WorkspaceInfo, offline: bool) -> Result<()> {
let targets = workspace_info.asset_dep_versions();
if targets.is_empty() {
fn materialize_asset_deps(
workspace_info: &WorkspaceInfo,
selected_kicad_assets: &BTreeMap<String, Version>,
offline: bool,
) -> Result<()> {
if selected_kicad_assets.is_empty() {
return Ok(());
}

let workspace_cache = workspace_info.root.join(".pcb/cache");
let missing: Vec<(String, Version)> = targets
let missing: Vec<(String, Version)> = selected_kicad_assets
.iter()
.filter(|(repo, version)| {
!workspace_cache
Expand Down Expand Up @@ -2207,9 +2232,48 @@ mod tests {

let mut workspace = workspace_with_root_config(config);
workspace.root = temp.path().to_path_buf();
let err = materialize_asset_deps(&workspace, true)
let selected_kicad_assets = BTreeMap::from([(
"gitlab.com/kicad/libraries/kicad-symbols".to_string(),
Version::new(9, 0, 0),
)]);
let err = materialize_asset_deps(&workspace, &selected_kicad_assets, true)
.expect_err("expected offline mode to require cached asset deps");

assert!(err.to_string().contains("not cached"));
}

#[test]
fn test_build_native_resolution_map_uses_selected_kicad_asset_version() {
let temp = TempDir::new().unwrap();
let root = temp.path().to_path_buf();
let cache_root = root.join(".pcb/cache/gitlab.com/kicad/libraries/kicad-symbols/9.0.8");
std::fs::create_dir_all(&cache_root).unwrap();

let mut config = PcbToml::default();
config.dependencies.insert(
"gitlab.com/kicad/libraries/kicad-symbols".to_string(),
DependencySpec::Version("9.0.3".to_string()),
);

let mut workspace = workspace_with_root_config(config.clone());
workspace.root = root.clone();

let selected_kicad_assets = BTreeMap::from([(
"gitlab.com/kicad/libraries/kicad-symbols".to_string(),
Version::new(9, 0, 8),
)]);
let resolutions = build_native_resolution_map(
&workspace,
&HashMap::new(),
&selected_kicad_assets,
&BTreeMap::new(),
);
let deps = resolutions
.get(&root)
.expect("expected workspace root dependency map");
let resolved = deps
.get("gitlab.com/kicad/libraries/kicad-symbols")
.expect("expected symbols dependency to resolve");
assert_eq!(resolved, &cache_root);
}
}
74 changes: 59 additions & 15 deletions crates/pcb/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ fn is_patch_bump(current: &Version, candidate: &Version) -> bool {
candidate.major == current.major && candidate.minor == current.minor && candidate > current
}

fn select_update_candidates<'a>(
available: &'a [Version],
current: &Version,
policy: AutoUpdatePolicy,
) -> (Option<&'a Version>, Option<&'a Version>) {
let current_family = semver_family(current);
let stable_newer: Vec<&Version> = available
.iter()
.filter(|v| v.pre.is_empty() && *v > current)
.collect();

let non_breaking = match policy {
AutoUpdatePolicy::SemverFamily => stable_newer
.iter()
.copied()
.find(|v| semver_family(v) == current_family),
AutoUpdatePolicy::PatchOnly => stable_newer
.iter()
.copied()
.find(|v| is_patch_bump(current, v)),
};

let breaking = stable_newer
.iter()
.copied()
.find(|v| semver_family(v) != current_family);

(non_breaking, breaking)
}

fn detect_update_scope(workspace: &WorkspaceInfo, start_path: &Path) -> UpdateScope {
// Start from a directory; if a file was provided, use its parent dir.
let candidate_dir = if start_path.is_file() {
Expand Down Expand Up @@ -326,17 +356,7 @@ fn find_version_updates(
continue;
};

let current_family = semver_family(&current);

// Auto-update policy (non-breaking)
let non_breaking = match policy {
AutoUpdatePolicy::SemverFamily => available
.iter()
.find(|v| semver_family(v) == current_family && *v > &current),
AutoUpdatePolicy::PatchOnly => {
available.iter().find(|v| is_patch_bump(&current, v))
}
};
let (non_breaking, breaking) = select_update_candidates(available, &current, policy);

if let Some(v) = non_breaking {
pending.push(PendingUpdate {
Expand All @@ -349,10 +369,7 @@ fn find_version_updates(
}

// Breaking update (different family)
if let Some(v) = available
.iter()
.find(|v| semver_family(v) != current_family && *v > &current)
{
if let Some(v) = breaking {
pending.push(PendingUpdate {
url: url.clone(),
current: current.clone(),
Expand Down Expand Up @@ -387,6 +404,33 @@ mod tests {
assert!(!is_patch_bump(&cur0, &Version::parse("0.4.0").unwrap()));
}

#[test]
fn test_select_update_candidates_ignores_prereleases() {
let current = Version::parse("9.0.7").unwrap();
let rc_only = vec![
Version::parse("10.0.0-rc1").unwrap(),
Version::parse("9.0.8-rc1").unwrap(),
];
let with_stable = vec![
Version::parse("10.0.0-rc1").unwrap(),
Version::parse("9.0.8").unwrap(),
Version::parse("9.0.8-rc1").unwrap(),
];

let (non_breaking, breaking) =
select_update_candidates(&rc_only, &current, AutoUpdatePolicy::SemverFamily);
assert!(non_breaking.is_none());
assert!(breaking.is_none());

let (non_breaking, breaking) =
select_update_candidates(&with_stable, &current, AutoUpdatePolicy::SemverFamily);
assert_eq!(
non_breaking.map(|v| v.to_string()),
Some("9.0.8".to_string())
);
assert!(breaking.is_none());
}

#[test]
fn test_detect_update_scope_member_package_dir() {
let td = tempfile::tempdir().unwrap();
Expand Down