diff --git a/README.md b/README.md index 86d093e..abc0b3c 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,17 @@ providers: - strip-links ``` +`target` may also be a map when a provider needs different roots for different +content kinds. Missing kinds fall back to `default`; unknown keys are rejected: + +```yaml +providers: + example: + target: + default: ".example" + skills: ".agents" +``` + ## Usage Every command takes its inputs as named flags. Source modules use `--source ` (defaults to `.` for in-tree commands), targets use `--target `, upstreams use `--upstream `. There are no positional path arguments. diff --git a/src/assemble/pipeline_tests.rs b/src/assemble/pipeline_tests.rs index 5a3372a..bf86f35 100644 --- a/src/assemble/pipeline_tests.rs +++ b/src/assemble/pipeline_tests.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use super::pipeline::{self, SourceFile}; -use crate::provider::ProviderConfig; +use crate::provider::{ProviderConfig, ProviderTarget}; fn make_source<'a>(relative_path: &'a str, content: &'a str, passthrough: bool) -> SourceFile<'a> { SourceFile { @@ -14,7 +14,7 @@ fn make_source<'a>(relative_path: &'a str, content: &'a str, passthrough: bool) fn make_provider(assembly: Option>) -> ProviderConfig { ProviderConfig { - target: ".test".to_string(), + target: ProviderTarget::Single(".test".to_string()), assembly: assembly.map(|v| v.into_iter().map(String::from).collect()), deploy: None, keep_fields: None, @@ -155,7 +155,7 @@ fn unknown_rule_collected_as_error() { providers.insert( "bad".to_string(), ProviderConfig { - target: ".bad".to_string(), + target: ProviderTarget::Single(".bad".to_string()), assembly: Some(vec!["nonexistent-rule".to_string()]), deploy: None, keep_fields: None, diff --git a/src/cli/config/tests.rs b/src/cli/config/tests.rs index 046dad0..51e3aee 100644 --- a/src/cli/config/tests.rs +++ b/src/cli/config/tests.rs @@ -52,7 +52,7 @@ fn load_providers_returns_embedded_defaults() { fn load_providers_module_config_overrides_target() { let module_config = "providers:\n claude:\n target: .custom-claude\n"; let providers = load_providers(module_config).unwrap(); - assert_eq!(providers["claude"].target, ".custom-claude"); + assert_eq!(providers["claude"].default_target(), ".custom-claude"); } #[test] diff --git a/src/cli/dashboard/server.rs b/src/cli/dashboard/server.rs index 417ec5e..96bf630 100644 --- a/src/cli/dashboard/server.rs +++ b/src/cli/dashboard/server.rs @@ -70,7 +70,7 @@ fn load_provider_targets(root: &Path) -> Vec<(String, String)> { }; let mut targets: Vec<(String, String)> = providers .into_iter() - .map(|(name, config)| (name, config.target)) + .map(|(name, config)| (name, config.default_target().to_string())) .collect(); targets.sort_by(|a, b| a.0.cmp(&b.0)); targets diff --git a/src/cli/deploy/mod.rs b/src/cli/deploy/mod.rs index 3ff9324..688960b 100644 --- a/src/cli/deploy/mod.rs +++ b/src/cli/deploy/mod.rs @@ -4,7 +4,7 @@ use commands::result::{ActionResult, DeployedFile, PrunedFile, SkipReason, Skipp use regex::Regex; use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::OnceLock; use crate::cli::config; @@ -62,160 +62,83 @@ pub fn execute( continue; } - let target_base = match effective_target { - Some(dir) => Path::new(dir).join(&provider_config.target), - None => Path::new(&provider_config.target).to_path_buf(), - }; + let mut manifests: HashMap> = + HashMap::new(); + let mut deployed_by_root: HashMap> = HashMap::new(); - if let Some(dir) = effective_target { - validate_target_boundary(&target_base, Path::new(dir))?; + for target_root in provider_config.target_roots() { + let target_base = resolve_target_base(target_root, effective_target); + if let Some(dir) = effective_target { + validate_target_boundary(&target_base, Path::new(dir))?; + } + deployed_by_root.entry(target_base.clone()).or_default(); + manifests + .entry(target_base.clone()) + .or_insert_with(|| load_deployed_manifest(&target_base)); } - let mut existing_manifest = load_deployed_manifest(&target_base); - let mut deployed_keys: HashSet = HashSet::new(); - - deploy_provider_files( - &build_provider_dir, - &target_base, - &mut existing_manifest, - &mut deployed_keys, - &mut result, - provider_name, - force, - )?; - - // Stale detection only when prune enabled. Pruned files are - // quarantined to /.trash// rather than deleted so - // accidental prunes are recoverable with a single mv. - if prune { - let stale_keys: Vec = existing_manifest - .iter() - .filter(|(key, _)| !deployed_keys.contains(*key)) - .filter(|(key, _)| { - ["agents/", "skills/", "rules/"] - .iter() - .any(|prefix| key.starts_with(prefix)) - }) - .filter(|(_, entry)| { - is_owned_by_module(entry, &target_base, module_name.as_deref()) - }) - .map(|(key, _)| key.clone()) - .collect(); - - if !stale_keys.is_empty() { - let stamp = chrono::Utc::now().format("%Y-%m-%d-%H%MZ").to_string(); - let trash_root = target_base.join(".trash").join(&stamp); - - let mut skipped_modified = 0; - for stale_key in &stale_keys { - let stale_path = target_base.join(stale_key); - let trash_dest = trash_root.join(stale_key); - - // Refuse to prune a file whose on-disk content no longer - // matches the fingerprint forge recorded — that signals - // local edits the user might want to keep. Same semantic as - // the deploy path uses to skip overwriting modified files; - // --force overrides both. - if !force - && stale_path.is_file() - && let Some(expected) = existing_manifest - .get(stale_key) - .map(|entry| entry.fingerprint.clone()) - && let Ok(current) = fs::read_to_string(&stale_path) - && manifest::content_sha256(¤t) != expected - { - eprintln!( - "forge prune: skipping {} (modified locally; pass --force to prune)", - stale_path.display() - ); - skipped_modified += 1; - continue; - } - - if dry_run { - eprintln!( - "forge prune: would move {} -> {}", - stale_path.display(), - trash_dest.display() - ); - continue; - } - - if let Some(parent) = trash_dest.parent() - && let Err(error) = fs::create_dir_all(parent) - { - eprintln!( - "warning: cannot create quarantine dir {}: {error}", - parent.display() - ); - continue; - } - - if stale_path.is_file() { - if let Err(error) = fs::rename(&stale_path, &trash_dest) { - eprintln!( - "warning: cannot quarantine {}: {error}", - stale_path.display() - ); - continue; - } - prune_empty_parents(stale_path.parent(), &target_base); - } - - let provenance_rel = manifest::provenance_path(stale_key); - let provenance_path = target_base.join(&provenance_rel); - if provenance_path.is_file() { - let provenance_trash = trash_root.join(&provenance_rel); - if let Some(parent) = provenance_trash.parent() { - let _ = fs::create_dir_all(parent); - } - let _ = fs::rename(&provenance_path, &provenance_trash); - prune_empty_parents(provenance_path.parent(), &target_base); - } - - existing_manifest.remove(stale_key); - result.pruned.push(PrunedFile { - target: stale_path.to_string_lossy().to_string(), - provider: provider_name.to_owned(), - }); - } + for kind in commands::provider::ContentKind::ALL { + let kind_dir = build_provider_dir.join(kind.as_str()); + if !kind_dir.is_dir() { + continue; + } - let action = if dry_run { "would move" } else { "moved" }; - let pruned_count = stale_keys.len() - skipped_modified; - if pruned_count > 0 { - let entry_label = if pruned_count == 1 { - "entry" - } else { - "entries" - }; - eprintln!( - "forge prune: {action} {pruned_count} stale {entry_label} to {}/.trash/{}/; recoverable via mv", - target_base.display(), - stamp - ); - } - if skipped_modified > 0 { - let entry_label = if skipped_modified == 1 { - "entry" - } else { - "entries" - }; - eprintln!( - "forge prune: skipped {skipped_modified} modified {entry_label}; pass --force to prune" - ); - } + let target_base = + resolve_target_base(provider_config.target_for_kind(*kind), effective_target); + if let Some(dir) = effective_target { + validate_target_boundary(&target_base, Path::new(dir))?; } + + let existing_manifest = manifests + .entry(target_base.clone()) + .or_insert_with(|| load_deployed_manifest(&target_base)); + let deployed_keys = deployed_by_root.entry(target_base.clone()).or_default(); + + deploy_provider_kind_files( + &kind_dir, + *kind, + &target_base, + existing_manifest, + deployed_keys, + &mut result, + provider_name, + force, + )?; } - write_manifest(&target_base, &existing_manifest)?; + for (target_base, mut existing_manifest) in manifests { + let deployed_keys = deployed_by_root.remove(&target_base).unwrap_or_default(); + if prune { + prune_stale_files( + &target_base, + &mut existing_manifest, + &deployed_keys, + &mut result, + provider_name, + module_name.as_deref(), + force, + dry_run, + ); + } + write_manifest(&target_base, &existing_manifest)?; + } } Ok(result) } -/// Deploy all content kinds (agents, skills, rules) for a single provider. -fn deploy_provider_files( - build_provider_dir: &Path, +fn resolve_target_base(target_root: &str, effective_target: Option<&str>) -> PathBuf { + match effective_target { + Some(dir) => Path::new(dir).join(target_root), + None => Path::new(target_root).to_path_buf(), + } +} + +/// Deploy one content kind for a single provider. +#[allow(clippy::too_many_arguments)] +fn deploy_provider_kind_files( + kind_dir: &Path, + kind: commands::provider::ContentKind, target_base: &Path, new_manifest: &mut HashMap, deployed_keys: &mut HashSet, @@ -223,47 +146,71 @@ fn deploy_provider_files( provider_name: &str, force: bool, ) -> Result<(), Error> { - for kind in commands::provider::ContentKind::ALL { - let kind_dir = build_provider_dir.join(kind.as_str()); - if !kind_dir.is_dir() { + let files = collect_files_recursive(kind_dir)?; + + for build_path in files { + if build_path.extension().unwrap_or_default() == "yaml" { continue; } - let files = collect_files_recursive(&kind_dir)?; + let relative = build_path + .strip_prefix(kind_dir) + .unwrap_or(&build_path) + .to_string_lossy() + .to_string(); + let manifest_key = format!("{kind}/{relative}"); + deployed_keys.insert(manifest_key.clone()); + let target_path = target_base.join(kind.as_str()).join(&relative); + + let build_content = config::read_file(&build_path)?; + let build_fingerprint = manifest::content_sha256(&build_content); + let provenance_relative = manifest::provenance_path(&manifest_key); + let sidecar_source = manifest::sidecar_path(&build_path); + + if sidecar_source.is_file() { + let provenance_target = target_base.join(&provenance_relative); + let _ = copy_file(&sidecar_source, &provenance_target); + } - for build_path in files { - if build_path.extension().unwrap_or_default() == "yaml" { - continue; + let target_content = fs::read_to_string(&target_path).ok(); + let status = manifest::status( + target_content.as_deref(), + new_manifest.get(&manifest_key), + &build_fingerprint, + ); + + match status { + manifest::FileStatus::New | manifest::FileStatus::Stale => { + copy_file(&build_path, &target_path)?; + new_manifest.insert( + manifest_key, + manifest::ManifestEntry { + fingerprint: build_fingerprint.clone(), + provenance: Some(provenance_relative.clone()), + }, + ); + result.installed.push(DeployedFile { + source: build_path.to_string_lossy().to_string(), + target: target_path.to_string_lossy().to_string(), + provider: provider_name.to_owned(), + }); } - - let relative = build_path - .strip_prefix(&kind_dir) - .unwrap_or(&build_path) - .to_string_lossy() - .to_string(); - let manifest_key = format!("{kind}/{relative}"); - deployed_keys.insert(manifest_key.clone()); - let target_path = target_base.join(kind.as_str()).join(&relative); - - let build_content = config::read_file(&build_path)?; - let build_fingerprint = manifest::content_sha256(&build_content); - let provenance_relative = manifest::provenance_path(&manifest_key); - let sidecar_source = manifest::sidecar_path(&build_path); - - if sidecar_source.is_file() { - let provenance_target = target_base.join(&provenance_relative); - let _ = copy_file(&sidecar_source, &provenance_target); + manifest::FileStatus::Unchanged => { + new_manifest.insert( + manifest_key, + manifest::ManifestEntry { + fingerprint: build_fingerprint.clone(), + provenance: Some(provenance_relative.clone()), + }, + ); + result.skipped.push(SkippedFile { + target: target_path.to_string_lossy().to_string(), + provider: provider_name.to_owned(), + reason: SkipReason::Unchanged, + }); } - - let target_content = fs::read_to_string(&target_path).ok(); - let status = manifest::status( - target_content.as_deref(), - new_manifest.get(&manifest_key), - &build_fingerprint, - ); - - match status { - manifest::FileStatus::New | manifest::FileStatus::Stale => { + manifest::FileStatus::Modified => { + if force { copy_file(&build_path, &target_path)?; new_manifest.insert( manifest_key, @@ -277,50 +224,148 @@ fn deploy_provider_files( target: target_path.to_string_lossy().to_string(), provider: provider_name.to_owned(), }); - } - manifest::FileStatus::Unchanged => { - new_manifest.insert( - manifest_key, - manifest::ManifestEntry { - fingerprint: build_fingerprint.clone(), - provenance: Some(provenance_relative.clone()), - }, - ); + } else { result.skipped.push(SkippedFile { target: target_path.to_string_lossy().to_string(), provider: provider_name.to_owned(), - reason: SkipReason::Unchanged, + reason: SkipReason::UserModified, }); } - manifest::FileStatus::Modified => { - if force { - copy_file(&build_path, &target_path)?; - new_manifest.insert( - manifest_key, - manifest::ManifestEntry { - fingerprint: build_fingerprint.clone(), - provenance: Some(provenance_relative.clone()), - }, - ); - result.installed.push(DeployedFile { - source: build_path.to_string_lossy().to_string(), - target: target_path.to_string_lossy().to_string(), - provider: provider_name.to_owned(), - }); - } else { - result.skipped.push(SkippedFile { - target: target_path.to_string_lossy().to_string(), - provider: provider_name.to_owned(), - reason: SkipReason::UserModified, - }); - } - } } } } Ok(()) } +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] +fn prune_stale_files( + target_base: &Path, + existing_manifest: &mut HashMap, + deployed_keys: &HashSet, + result: &mut ActionResult, + provider_name: &str, + module_name: Option<&str>, + force: bool, + dry_run: bool, +) { + let stale_keys: Vec = existing_manifest + .iter() + .filter(|(key, _)| !deployed_keys.contains(*key)) + .filter(|(key, _)| { + ["agents/", "skills/", "rules/"] + .iter() + .any(|prefix| key.starts_with(prefix)) + }) + .filter(|(_, entry)| is_owned_by_module(entry, target_base, module_name)) + .map(|(key, _)| key.clone()) + .collect(); + + if stale_keys.is_empty() { + return; + } + + let stamp = chrono::Utc::now().format("%Y-%m-%d-%H%MZ").to_string(); + let trash_root = target_base.join(".trash").join(&stamp); + let mut skipped_modified = 0; + + for stale_key in &stale_keys { + let stale_path = target_base.join(stale_key); + let trash_dest = trash_root.join(stale_key); + + // Refuse to prune a file whose on-disk content no longer matches the + // recorded fingerprint: that signals local edits the user might want + // to keep. --force overrides both deploy and prune protection. + if !force + && stale_path.is_file() + && let Some(expected) = existing_manifest + .get(stale_key) + .map(|entry| entry.fingerprint.clone()) + && let Ok(current) = fs::read_to_string(&stale_path) + && manifest::content_sha256(¤t) != expected + { + eprintln!( + "forge prune: skipping {} (modified locally; pass --force to prune)", + stale_path.display() + ); + skipped_modified += 1; + continue; + } + + if dry_run { + eprintln!( + "forge prune: would move {} -> {}", + stale_path.display(), + trash_dest.display() + ); + continue; + } + + if let Some(parent) = trash_dest.parent() + && let Err(error) = fs::create_dir_all(parent) + { + eprintln!( + "warning: cannot create quarantine dir {}: {error}", + parent.display() + ); + continue; + } + + if stale_path.is_file() { + if let Err(error) = fs::rename(&stale_path, &trash_dest) { + eprintln!( + "warning: cannot quarantine {}: {error}", + stale_path.display() + ); + continue; + } + prune_empty_parents(stale_path.parent(), target_base); + } + + let provenance_rel = manifest::provenance_path(stale_key); + let provenance_path = target_base.join(&provenance_rel); + if provenance_path.is_file() { + let provenance_trash = trash_root.join(&provenance_rel); + if let Some(parent) = provenance_trash.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::rename(&provenance_path, &provenance_trash); + prune_empty_parents(provenance_path.parent(), target_base); + } + + existing_manifest.remove(stale_key); + result.pruned.push(PrunedFile { + target: stale_path.to_string_lossy().to_string(), + provider: provider_name.to_owned(), + }); + } + + let action = if dry_run { "would move" } else { "moved" }; + let pruned_count = stale_keys.len() - skipped_modified; + if pruned_count > 0 { + let entry_label = if pruned_count == 1 { + "entry" + } else { + "entries" + }; + eprintln!( + "forge prune: {action} {pruned_count} stale {entry_label} to {}/.trash/{}/; recoverable via mv", + target_base.display(), + stamp + ); + } + if skipped_modified > 0 { + let entry_label = if skipped_modified == 1 { + "entry" + } else { + "entries" + }; + eprintln!( + "forge prune: skipped {skipped_modified} modified {entry_label}; pass --force to prune" + ); + } +} + /// Keep only the provider entries the user requested. Each requested name is /// matched against provider keys, target directories, and aliases (the same /// rules `ProviderConfig::matches_target` uses elsewhere). Unknown names diff --git a/src/cli/drift/scope.rs b/src/cli/drift/scope.rs index fe91291..55c14ba 100644 --- a/src/cli/drift/scope.rs +++ b/src/cli/drift/scope.rs @@ -16,6 +16,7 @@ use std::path::Path; use super::{DriftEntry, DriftResult, DriftStatus, compare_file_content, print_drift_result}; use crate::cli::config; use crate::cli::deploy::{is_owned_by_module, load_deployed_manifest}; +use commands::provider::{ContentKind, ProviderConfig}; const CONTENT_PREFIXES: [&str; 3] = ["agents/", "skills/", "rules/"]; @@ -57,7 +58,8 @@ pub fn execute( compare_provider( &mut result, &build_dir, - &base.join(&provider_config.target), + base, + provider_config, provider_name, module_name, &ignored, @@ -97,7 +99,8 @@ pub fn execute( fn compare_provider( result: &mut DriftResult, build_dir: &Path, - deployed_base: &Path, + target_base: &Path, + provider_config: &ProviderConfig, provider_name: &str, module_name: Option<&str>, ignored: &HashSet<&str>, @@ -105,6 +108,10 @@ fn compare_provider( let build_files = collect_content_files(build_dir); for (relative, build_content) in &build_files { + let Some(kind) = kind_for_relative(relative) else { + continue; + }; + let deployed_base = target_base.join(provider_config.target_for_kind(kind)); let deployed_path = deployed_base.join(relative); match fs::read_to_string(&deployed_path) { Ok(deployed_content) => { @@ -126,14 +133,23 @@ fn compare_provider( // This module's deployed files (per the target manifest + provenance) that // are no longer built — stale deployments that should be pruned. - for (key, entry) in load_deployed_manifest(deployed_base) { - if build_files.contains_key(&key) || !is_content_key(&key) { - continue; - } - if is_owned_by_module(&entry, deployed_base, module_name) { - result - .entries - .push(only_entry(&key, DriftStatus::UpstreamOnly, provider_name)); + for target_root in provider_config.target_roots() { + let deployed_base = target_base.join(target_root); + for (key, entry) in load_deployed_manifest(&deployed_base) { + if !is_content_key(&key) { + continue; + } + if let Some(kind) = kind_for_relative(&key) { + let expected_base = target_base.join(provider_config.target_for_kind(kind)); + if expected_base == deployed_base && build_files.contains_key(&key) { + continue; + } + } + if is_owned_by_module(&entry, &deployed_base, module_name) { + result + .entries + .push(only_entry(&key, DriftStatus::UpstreamOnly, provider_name)); + } } } } @@ -155,6 +171,15 @@ fn is_content_key(key: &str) -> bool { .any(|prefix| key.starts_with(prefix)) } +fn kind_for_relative(relative: &str) -> Option { + match relative.split_once('/').map(|(kind, _)| kind) { + Some("agents") => Some(ContentKind::Agents), + Some("skills") => Some(ContentKind::Skills), + Some("rules") => Some(ContentKind::Rules), + _ => None, + } +} + /// Collect content files under a provider build directory, keyed by their path /// relative to it. Sidecar directories, `.manifest`, and dotfiles are skipped. fn collect_content_files(build_dir: &Path) -> BTreeMap { diff --git a/src/cli/drift/scope/tests.rs b/src/cli/drift/scope/tests.rs index 68b64ab..ecafe53 100644 --- a/src/cli/drift/scope/tests.rs +++ b/src/cli/drift/scope/tests.rs @@ -1,6 +1,7 @@ use super::super::{DriftResult, DriftStatus}; use super::*; use commands::manifest; +use commands::provider::{ProviderConfig, ProviderTarget}; use tempfile::TempDir; fn write(path: &std::path::Path, content: &str) { @@ -43,10 +44,21 @@ fn deploy_owned_file( fn run(build: &std::path::Path, deployed: &std::path::Path, module: &str) -> DriftResult { let mut result = DriftResult::default(); + let provider_config = ProviderConfig { + target: ProviderTarget::Single(".".to_string()), + assembly: None, + deploy: None, + keep_fields: None, + models: None, + effort: None, + aliases: None, + model: None, + }; compare_provider( &mut result, build, deployed, + &provider_config, "claude", Some(module), &HashSet::new(), diff --git a/src/cli/release/mod.rs b/src/cli/release/mod.rs index 8904fcf..e29b674 100644 --- a/src/cli/release/mod.rs +++ b/src/cli/release/mod.rs @@ -62,7 +62,7 @@ pub fn execute(path: &str, embed: bool) -> Result { })?; for (provider_name, provider_config) in &providers { - let staged_provider = staging_dir.join(&provider_config.target); + let staged_provider = staging_dir.join(provider_config.default_target()); if !staged_provider.is_dir() { continue; } @@ -81,7 +81,7 @@ pub fn execute(path: &str, embed: bool) -> Result { })?; // Move installed provider tree (including .manifest) into wrapper - let dotfolder = wrapper_dir.join(&provider_config.target); + let dotfolder = wrapper_dir.join(provider_config.default_target()); fs::rename(&staged_provider, &dotfolder).map_err(|error| { Error::new( ErrorKind::Io, @@ -109,7 +109,7 @@ pub fn execute(path: &str, embed: bool) -> Result { let _ = fs::remove_dir_all(&wrapper_dir); result.installed.push(DeployedFile { - source: provider_config.target.clone(), + source: provider_config.default_target().to_string(), target: tarball_path.to_string_lossy().to_string(), provider: provider_name.clone(), }); diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 53eae6e..0c32f72 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -54,7 +54,7 @@ impl AssemblyRule { #[derive(Debug, Clone, Deserialize)] pub struct ProviderConfig { - pub target: String, + pub target: ProviderTarget, pub assembly: Option>, pub deploy: Option>, pub keep_fields: Option>>, @@ -68,12 +68,28 @@ pub struct ProviderConfig { } impl ProviderConfig { + pub fn default_target(&self) -> &str { + self.target.default_target() + } + + pub fn target_for_kind(&self, kind: ContentKind) -> &str { + self.target.target_for_kind(kind) + } + + pub fn target_roots(&self) -> Vec<&str> { + self.target.roots() + } + pub fn matches_target(&self, target_name: &str, provider_key: &str) -> bool { if target_name == provider_key { return true; } - if target_name == self.target || target_name == self.target.trim_start_matches('.') { + if self + .target_roots() + .iter() + .any(|target| target_name == *target || target_name == target.trim_start_matches('.')) + { return true; } @@ -83,6 +99,57 @@ impl ProviderConfig { } } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum ProviderTarget { + Single(String), + ByKind(ProviderTargetMap), +} + +impl ProviderTarget { + pub fn default_target(&self) -> &str { + match self { + Self::Single(target) => target, + Self::ByKind(targets) => &targets.default, + } + } + + pub fn target_for_kind(&self, kind: ContentKind) -> &str { + match self { + Self::Single(target) => target, + Self::ByKind(targets) => match kind { + ContentKind::Agents => targets.agents.as_deref().unwrap_or(&targets.default), + ContentKind::Skills => targets.skills.as_deref().unwrap_or(&targets.default), + ContentKind::Rules => targets.rules.as_deref().unwrap_or(&targets.default), + }, + } + } + + pub fn roots(&self) -> Vec<&str> { + let mut roots = vec![self.default_target()]; + if let Self::ByKind(targets) = self { + for target in [&targets.agents, &targets.skills, &targets.rules] + .into_iter() + .flatten() + { + if !roots.contains(&target.as_str()) { + roots.push(target.as_str()); + } + } + } + roots + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ProviderTargetMap { + pub default: String, + pub agents: Option, + pub skills: Option, + pub rules: Option, +} + // --- Loading --- #[derive(Deserialize)] diff --git a/src/provider/tests.rs b/src/provider/tests.rs index dc16a43..566651b 100644 --- a/src/provider/tests.rs +++ b/src/provider/tests.rs @@ -25,9 +25,32 @@ fn load_providers_parses_all_providers() { fn load_providers_reads_target() { let providers = load_providers(DEFAULTS).unwrap(); - assert_eq!(providers["claude"].target, ".claude"); - assert_eq!(providers["gemini"].target, ".gemini"); - assert_eq!(providers["agentskills"].target, ".agents"); + assert_eq!(providers["claude"].default_target(), ".claude"); + assert_eq!(providers["gemini"].default_target(), ".gemini"); + assert_eq!(providers["agentskills"].default_target(), ".agents"); +} + +#[test] +fn load_providers_reads_target_map() { + let providers = load_providers( + "providers:\n codex:\n target:\n default: .codex\n skills: .agents\n", + ) + .unwrap(); + + let codex = &providers["codex"]; + assert_eq!(codex.default_target(), ".codex"); + assert_eq!(codex.target_for_kind(ContentKind::Agents), ".codex"); + assert_eq!(codex.target_for_kind(ContentKind::Rules), ".codex"); + assert_eq!(codex.target_for_kind(ContentKind::Skills), ".agents"); +} + +#[test] +fn load_providers_rejects_unknown_target_map_key() { + let result = load_providers( + "providers:\n codex:\n target:\n default: .codex\n skillz: .agents\n", + ); + + assert!(result.is_err()); } #[test] @@ -182,7 +205,7 @@ fn map_tool_passes_through_unmapped() { fn provider_with_aliases(target: &str, aliases: Vec<&str>) -> ProviderConfig { ProviderConfig { - target: target.to_string(), + target: ProviderTarget::Single(target.to_string()), assembly: None, deploy: None, keep_fields: None, @@ -226,7 +249,7 @@ fn matches_target_rejects_unknown() { #[test] fn matches_target_no_aliases() { let config = ProviderConfig { - target: ".opencode".to_string(), + target: ProviderTarget::Single(".opencode".to_string()), assembly: None, deploy: None, keep_fields: None, diff --git a/src/tui/app.rs b/src/tui/app.rs index 0a42caa..88388eb 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1758,7 +1758,7 @@ pub fn load_provider_targets(root: &Path) -> Vec<(String, String)> { }; let mut targets: Vec<(String, String)> = providers .into_iter() - .map(|(name, config)| (name, config.target)) + .map(|(name, config)| (name, config.default_target().to_string())) .collect(); targets.sort_by(|a, b| a.0.cmp(&b.0)); targets diff --git a/src/yaml/merge.rs b/src/yaml/merge.rs index 42324ec..32d05bb 100644 --- a/src/yaml/merge.rs +++ b/src/yaml/merge.rs @@ -65,11 +65,19 @@ fn merge_value(base: &mut Value, overlay: Value, key_path: &str) { } } } - (Value::Mapping(_), overlay_value) => { - warn_type_conflict(key_path, "mapping", describe_value(&overlay_value)); + (base_value @ Value::Mapping(_), overlay_value) => { + if replace_on_type_conflict(key_path) { + *base_value = overlay_value; + } else { + warn_type_conflict(key_path, "mapping", describe_value(&overlay_value)); + } } - (base_value, Value::Mapping(_)) => { - warn_type_conflict(key_path, describe_value(base_value), "mapping"); + (base_value, overlay_value @ Value::Mapping(_)) => { + if replace_on_type_conflict(key_path) { + *base_value = overlay_value; + } else { + warn_type_conflict(key_path, describe_value(base_value), "mapping"); + } } (base_value, overlay) => { *base_value = overlay; @@ -77,6 +85,10 @@ fn merge_value(base: &mut Value, overlay: Value, key_path: &str) { } } +fn replace_on_type_conflict(key_path: &str) -> bool { + key_path.starts_with("providers.") && key_path.ends_with(".target") +} + fn warn_type_conflict(key_path: &str, base_type: &str, overlay_type: &str) { let location = if key_path.is_empty() { "".to_string() diff --git a/tests/deploy.rs b/tests/deploy.rs index 85115a7..7bc902e 100644 --- a/tests/deploy.rs +++ b/tests/deploy.rs @@ -409,6 +409,57 @@ fn install_deploys_skill_to_agentskills_provider() { // --- Manifest tests --- +#[test] +fn install_routes_content_kinds_to_target_map_roots() { + let module_directory = tempfile::tempdir().unwrap(); + let target_directory = tempfile::tempdir().unwrap(); + + scaffold_module(module_directory.path()); + fs::write( + module_directory.path().join("defaults.yaml"), + "providers:\n claude:\n target:\n default: .claude\n skills: .agents\n", + ) + .unwrap(); + create_skill(module_directory.path(), "MappedSkill"); + create_rule(module_directory.path(), "MappedRule"); + + forge() + .args([ + "install", + "--source", + module_directory.path().to_str().unwrap(), + "--target", + target_directory.path().to_str().unwrap(), + "--provider", + "claude", + ]) + .assert() + .success(); + + assert!( + target_directory + .path() + .join(".agents/skills/MappedSkill/SKILL.md") + .is_file(), + "target.skills override should route skills to .agents" + ); + assert!( + target_directory + .path() + .join(".claude/rules/MappedRule.md") + .is_file(), + "missing target.rules should fall back to target.default" + ); + assert!( + target_directory.path().join(".agents/.manifest").is_file(), + "mapped skill root should get its own manifest" + ); + assert!( + target_directory.path().join(".claude/.manifest").is_file(), + "default root should get its own manifest" + ); +} + #[test] fn install_creates_nested_manifest() { let module_directory = tempfile::tempdir().unwrap();