use std::path::PathBuf;
use crate::config::{Config, DependencyEntry, FilterConfig, LocalConfig, OverrideEntry};
use crate::error::{ConfigError, MarsError};
use crate::types::{ItemName, MarsContext, RenameMap, SourceName};
#[derive(Debug, Clone)]
pub enum ConfigMutation {
UpsertDependency {
name: SourceName,
entry: DependencyEntry,
},
BatchUpsert(Vec<(SourceName, DependencyEntry)>),
RemoveDependency { name: SourceName },
SetOverride {
source_name: SourceName,
local_path: PathBuf,
},
ClearOverride { source_name: SourceName },
SetRename {
source_name: SourceName,
from: String,
to: String,
},
}
#[derive(Debug, Clone)]
pub struct DependencyUpsertChange {
pub name: SourceName,
pub already_exists: bool,
pub old_filter: Option<FilterConfig>,
pub new_filter: FilterConfig,
}
#[derive(Debug, Clone)]
pub enum LinkMutation {
Set { target: String },
Clear { target: String },
}
pub fn mutate_link_config(ctx: &MarsContext, mutation: &LinkMutation) -> Result<(), MarsError> {
let mars_dir = ctx.project_root.join(".mars");
std::fs::create_dir_all(&mars_dir)?;
let lock_path = mars_dir.join("sync.lock");
let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
let mut config = crate::config::load(&ctx.project_root)?;
match mutation {
LinkMutation::Set { target } => {
if !config.settings.links.contains(target) {
config.settings.links.push(target.clone());
}
}
LinkMutation::Clear { target } => {
config.settings.links.retain(|l| l != target);
}
}
crate::config::save(&ctx.project_root, &config)?;
Ok(())
}
pub fn apply_config_mutation(
config: &mut Config,
mutation: &ConfigMutation,
) -> Result<(), MarsError> {
apply_mutation(config, mutation).map(|_| ())
}
pub(crate) fn apply_mutation(
config: &mut Config,
mutation: &ConfigMutation,
) -> Result<Vec<DependencyUpsertChange>, MarsError> {
match mutation {
ConfigMutation::UpsertDependency { name, entry } => {
Ok(vec![apply_dependency_upsert(config, name, entry)])
}
ConfigMutation::BatchUpsert(entries) => {
let mut changes = Vec::with_capacity(entries.len());
for (name, entry) in entries {
changes.push(apply_dependency_upsert(config, name, entry));
}
Ok(changes)
}
ConfigMutation::RemoveDependency { name } => {
if !config.dependencies.contains_key(name) {
return Err(MarsError::Source {
source_name: name.to_string(),
message: format!("dependency `{name}` not found in mars.toml"),
});
}
config.dependencies.shift_remove(name);
Ok(Vec::new())
}
ConfigMutation::SetOverride { source_name, .. } => {
if !config.dependencies.contains_key(source_name) {
return Err(MarsError::Source {
source_name: source_name.to_string(),
message: format!("dependency `{source_name}` not found in mars.toml"),
});
}
Ok(Vec::new())
}
ConfigMutation::SetRename {
source_name,
from,
to,
} => {
let dep =
config
.dependencies
.get_mut(source_name)
.ok_or_else(|| MarsError::Source {
source_name: source_name.to_string(),
message: format!("dependency `{source_name}` not found in mars.toml"),
})?;
let rename_map = dep.filter.rename.get_or_insert_with(RenameMap::new);
rename_map.insert(ItemName::from(from.as_str()), ItemName::from(to.as_str()));
Ok(Vec::new())
}
ConfigMutation::ClearOverride { .. } => Ok(Vec::new()),
}
}
pub(crate) fn apply_local_mutation(local: &mut LocalConfig, mutation: &ConfigMutation) {
match mutation {
ConfigMutation::SetOverride {
source_name,
local_path,
} => {
local.overrides.insert(
source_name.clone(),
OverrideEntry {
path: local_path.clone(),
},
);
}
ConfigMutation::ClearOverride { source_name } => {
local.overrides.shift_remove(source_name);
}
ConfigMutation::UpsertDependency { .. }
| ConfigMutation::BatchUpsert(..)
| ConfigMutation::RemoveDependency { .. }
| ConfigMutation::SetRename { .. } => {}
}
}
fn apply_dependency_upsert(
config: &mut Config,
name: &SourceName,
entry: &DependencyEntry,
) -> DependencyUpsertChange {
if let Some(existing) = config.dependencies.get_mut(name) {
let old_filter = existing.filter.clone();
existing.url = entry.url.clone();
existing.path = entry.path.clone();
existing.version = entry.version.clone();
if entry.filter.has_any_filter() {
let rename = existing.filter.rename.take();
existing.filter = entry.filter.clone();
existing.filter.rename = rename;
}
DependencyUpsertChange {
name: name.clone(),
already_exists: true,
old_filter: Some(old_filter),
new_filter: existing.filter.clone(),
}
} else {
config.dependencies.insert(name.clone(), entry.clone());
DependencyUpsertChange {
name: name.clone(),
already_exists: false,
old_filter: None,
new_filter: entry.filter.clone(),
}
}
}
pub(crate) fn is_config_not_found(error: &MarsError) -> bool {
matches!(error, MarsError::Config(ConfigError::NotFound { .. }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_mutation_atomic_filter_replacement() {
let mut config = Config::default();
let entry1 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: Some("v1".into()),
filter: FilterConfig {
agents: Some(vec!["reviewer".into()]),
..FilterConfig::default()
},
};
apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry1,
},
)
.unwrap();
assert!(config.dependencies["base"].filter.agents.is_some());
let entry2 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: Some("v1".into()),
filter: FilterConfig {
only_skills: true,
..FilterConfig::default()
},
};
apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry2,
},
)
.unwrap();
let dep = &config.dependencies["base"];
assert!(dep.filter.only_skills);
assert!(
dep.filter.agents.is_none(),
"agents should be cleared by atomic replacement"
);
}
#[test]
fn apply_mutation_preserves_filters_on_version_bump() {
let mut config = Config::default();
let entry1 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: Some("v1".into()),
filter: FilterConfig {
agents: Some(vec!["coder".into()]),
..FilterConfig::default()
},
};
apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry1,
},
)
.unwrap();
let entry2 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: Some("v2".into()),
filter: FilterConfig::default(),
};
apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry2,
},
)
.unwrap();
let dep = &config.dependencies["base"];
assert_eq!(dep.version.as_deref(), Some("v2"));
assert_eq!(
dep.filter.agents.as_deref(),
Some(&["coder".into()][..]),
"agents filter should be preserved on version bump"
);
}
#[test]
fn apply_mutation_preserves_rename_on_filter_change() {
let mut config = Config::default();
let mut rename_map = RenameMap::new();
rename_map.insert("old".into(), "new".into());
let entry1 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: None,
filter: FilterConfig {
agents: Some(vec!["coder".into()]),
rename: Some(rename_map),
..FilterConfig::default()
},
};
apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry1,
},
)
.unwrap();
let entry2 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: None,
filter: FilterConfig {
only_skills: true,
..FilterConfig::default()
},
};
apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry2,
},
)
.unwrap();
let dep = &config.dependencies["base"];
assert!(dep.filter.only_skills);
assert!(dep.filter.agents.is_none());
assert!(
dep.filter.rename.is_some(),
"rename should be preserved across filter changes"
);
assert_eq!(
dep.filter.rename.as_ref().unwrap().get("old").unwrap(),
"new"
);
}
#[test]
fn apply_mutation_batch_upsert_applies_all_entries() {
let mut config = Config::default();
let batch = vec![
(
"base".into(),
DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: Some("v1".into()),
filter: FilterConfig::default(),
},
),
(
"workflow".into(),
DependencyEntry {
url: Some("https://github.com/org/workflow.git".into()),
path: None,
version: Some("v2".into()),
filter: FilterConfig::default(),
},
),
];
let changes = apply_mutation(&mut config, &ConfigMutation::BatchUpsert(batch)).unwrap();
assert_eq!(changes.len(), 2);
assert!(config.dependencies.contains_key("base"));
assert!(config.dependencies.contains_key("workflow"));
}
#[test]
fn apply_mutation_returns_old_and_new_filters_for_readd() {
let mut config = Config::default();
let entry1 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: Some("v1".into()),
filter: FilterConfig {
agents: Some(vec!["reviewer".into()]),
..FilterConfig::default()
},
};
apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry1,
},
)
.unwrap();
let entry2 = DependencyEntry {
url: Some("https://github.com/org/base.git".into()),
path: None,
version: Some("v2".into()),
filter: FilterConfig {
only_skills: true,
..FilterConfig::default()
},
};
let changes = apply_mutation(
&mut config,
&ConfigMutation::UpsertDependency {
name: "base".into(),
entry: entry2,
},
)
.unwrap();
assert_eq!(changes.len(), 1);
let change = &changes[0];
assert!(change.already_exists);
assert_eq!(change.name, "base");
assert_eq!(
change.old_filter.as_ref().and_then(|f| f.agents.as_deref()),
Some(&["reviewer".into()][..])
);
assert!(change.new_filter.only_skills);
assert!(change.new_filter.agents.is_none());
}
}