1use std::path::{Path, PathBuf};
4
5use indexmap::IndexMap;
6use serde::ser::SerializeMap;
7use serde::{Deserialize, Serialize};
8
9use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticLevel};
10use crate::error::{ConfigError, MarsError};
11use crate::types::managed_cmd;
12use crate::types::{
13 ItemName, RenameMap, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
14};
15
16pub mod migrations;
17pub mod routing_settings;
18pub mod targets;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
22pub struct Config {
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub package: Option<PackageInfo>,
25 #[serde(default)]
26 pub dependencies: IndexMap<SourceName, InstallDep>,
27 #[serde(
31 default,
32 skip_serializing_if = "IndexMap::is_empty",
33 rename = "local-dependencies"
34 )]
35 pub local_dependencies: IndexMap<SourceName, InstallDep>,
36 #[serde(default)]
37 pub settings: Settings,
38 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
39 pub models: IndexMap<String, crate::models::ModelAlias>,
40 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
41 pub agents: IndexMap<String, AgentOverlay>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct PackageInfo {
47 pub name: String,
48 pub version: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub description: Option<String>,
51}
52
53mod toml_path_serde {
54 use serde::{Deserialize, Deserializer, Serializer};
55 use std::path::{Path, PathBuf};
56
57 pub fn serialize<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
58 where
59 S: Serializer,
60 {
61 let s = path.to_string_lossy().replace('\\', "/");
62 serializer.serialize_str(&s)
63 }
64
65 pub fn deserialize<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
66 where
67 D: Deserializer<'de>,
68 {
69 let s = String::deserialize(deserializer)?;
70 Ok(PathBuf::from(s))
71 }
72}
73
74mod toml_path_serde_opt {
75 use serde::{Deserialize, Deserializer, Serializer};
76 use std::path::PathBuf;
77
78 pub fn serialize<S>(path: &Option<PathBuf>, serializer: S) -> Result<S::Ok, S::Error>
79 where
80 S: Serializer,
81 {
82 match path {
83 Some(path) => {
84 let s = path.to_string_lossy().replace('\\', "/");
85 serializer.serialize_some(&s)
86 }
87 None => serializer.serialize_none(),
88 }
89 }
90
91 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<PathBuf>, D::Error>
92 where
93 D: Deserializer<'de>,
94 {
95 let s = Option::<String>::deserialize(deserializer)?;
96 Ok(s.map(PathBuf::from))
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
103pub struct InstallDep {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub url: Option<SourceUrl>,
106 #[serde(
107 default,
108 skip_serializing_if = "Option::is_none",
109 with = "toml_path_serde_opt"
110 )]
111 pub path: Option<PathBuf>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub subpath: Option<SourceSubpath>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub version: Option<String>,
116 #[serde(flatten)]
117 pub filter: FilterConfig,
118}
119
120pub type DependencyEntry = InstallDep;
122
123#[derive(Debug, Clone, PartialEq)]
126pub struct ManifestDep {
127 pub url: Option<SourceUrl>,
128 pub path: Option<PathBuf>,
129 pub subpath: Option<SourceSubpath>,
130 pub version: Option<String>,
131 pub filter: FilterConfig,
132}
133
134#[derive(Debug, Clone, PartialEq)]
140pub struct Manifest {
141 pub package: PackageInfo,
142 pub dependencies: IndexMap<String, ManifestDep>,
143 pub models: IndexMap<String, crate::models::ModelAlias>,
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
148pub struct FilterConfig {
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub agents: Option<Vec<ItemName>>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub skills: Option<Vec<ItemName>>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub exclude: Option<Vec<ItemName>>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub rename: Option<RenameMap>,
157 #[serde(default, skip_serializing_if = "is_false")]
158 pub only_skills: bool,
159 #[serde(default, skip_serializing_if = "is_false")]
160 pub only_agents: bool,
161}
162
163#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
166pub struct ModelVisibility {
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub include: Option<Vec<String>>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub exclude: Option<Vec<String>>,
173}
174
175impl ModelVisibility {
176 pub fn validate(&self) -> Result<(), MarsError> {
177 Ok(())
178 }
179
180 pub fn is_empty(&self) -> bool {
181 self.include.is_none() && self.exclude.is_none()
182 }
183}
184
185#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
187pub struct AgentOverlay {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub model: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub harness: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub effort: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub approval: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub sandbox: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub autocompact: Option<i64>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub autocompact_pct: Option<i64>,
202 #[serde(
203 default,
204 rename = "model-policies",
205 skip_serializing_if = "Vec::is_empty"
206 )]
207 pub model_policies: Vec<ModelPolicyRule>,
208}
209
210#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "kebab-case")]
212pub enum ModelPolicyMatchType {
213 Model,
214 Alias,
215 ModelGlob,
216}
217
218#[derive(Debug, Clone, PartialEq)]
221pub struct ModelPolicyRule {
222 pub match_type: ModelPolicyMatchType,
223 pub match_value: String,
224 pub no_fallback: bool,
225 pub overrides: serde_yaml::Mapping,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum ModelPolicyRuleParseError {
230 RuleMustBeMapping { found: String },
231 MatchMissing,
232 MatchMustBeMapping { found: String },
233 MatchMustContainExactlyOne { found: String },
234 MatchKeyMustBeString { found: String },
235 UnknownMatchKey { key: String },
236 MatchValueMustBeString { key: String, found: String },
237 MatchValueEmpty { key: String },
238 OverrideMustBeMapping { found: String },
239 NoFallbackMustBeBoolean { found: String },
240}
241
242impl ModelPolicyRuleParseError {
243 fn deserialize_message(&self) -> String {
244 match self {
245 Self::MatchMustContainExactlyOne { .. }
246 | Self::MatchMissing
247 | Self::MatchMustBeMapping { .. } => {
248 "model policy `match` must contain exactly one of model, alias, model-glob"
249 .to_string()
250 }
251 Self::MatchKeyMustBeString { .. } => {
252 "model policy `match` key must be a string".to_string()
253 }
254 Self::MatchValueMustBeString { .. } => {
255 "model policy `match` value must be a string".to_string()
256 }
257 Self::MatchValueEmpty { .. } => {
258 "model policy `match` value must be a non-empty string".to_string()
259 }
260 Self::UnknownMatchKey { key } => {
261 format!(
262 "unknown model policy match key `{key}`; expected model, alias, or model-glob"
263 )
264 }
265 Self::OverrideMustBeMapping { .. } => {
266 "model policy `override` must be a mapping".to_string()
267 }
268 Self::NoFallbackMustBeBoolean { .. } => {
269 "model policy `no-fallback` must be a boolean".to_string()
270 }
271 Self::RuleMustBeMapping { .. } => "model policy rule must be a mapping".to_string(),
272 }
273 }
274}
275
276pub fn parse_model_policy_rule_value(
277 value: &serde_yaml::Value,
278) -> Result<ModelPolicyRule, ModelPolicyRuleParseError> {
279 let rule = value
280 .as_mapping()
281 .ok_or_else(|| ModelPolicyRuleParseError::RuleMustBeMapping {
282 found: format!("{value:?}"),
283 })?;
284
285 let match_value = rule.get(serde_yaml::Value::String("match".to_string()));
286 let match_mapping = match match_value {
287 Some(value) => {
288 value
289 .as_mapping()
290 .ok_or_else(|| ModelPolicyRuleParseError::MatchMustBeMapping {
291 found: format!("{value:?}"),
292 })?
293 }
294 None => return Err(ModelPolicyRuleParseError::MatchMissing),
295 };
296
297 let mut entries = match_mapping.iter();
298 let Some((match_key, match_value)) = entries.next() else {
299 return Err(ModelPolicyRuleParseError::MatchMustContainExactlyOne {
300 found: format!("{match_mapping:?}"),
301 });
302 };
303 if entries.next().is_some() {
304 return Err(ModelPolicyRuleParseError::MatchMustContainExactlyOne {
305 found: format!("{match_mapping:?}"),
306 });
307 }
308
309 let key =
310 match_key
311 .as_str()
312 .ok_or_else(|| ModelPolicyRuleParseError::MatchKeyMustBeString {
313 found: format!("{match_key:?}"),
314 })?;
315 let value =
316 match_value
317 .as_str()
318 .ok_or_else(|| ModelPolicyRuleParseError::MatchValueMustBeString {
319 key: key.to_string(),
320 found: format!("{match_value:?}"),
321 })?;
322 let match_value = value.trim().to_string();
323 if match_value.is_empty() {
324 return Err(ModelPolicyRuleParseError::MatchValueEmpty {
325 key: key.to_string(),
326 });
327 }
328
329 let match_type = match key {
330 "model" => ModelPolicyMatchType::Model,
331 "alias" => ModelPolicyMatchType::Alias,
332 "model-glob" => ModelPolicyMatchType::ModelGlob,
333 _ => {
334 return Err(ModelPolicyRuleParseError::UnknownMatchKey {
335 key: key.to_string(),
336 });
337 }
338 };
339
340 let overrides = match rule.get(serde_yaml::Value::String("override".to_string())) {
341 None | Some(serde_yaml::Value::Null) => serde_yaml::Mapping::new(),
342 Some(value) => value.as_mapping().cloned().ok_or_else(|| {
343 ModelPolicyRuleParseError::OverrideMustBeMapping {
344 found: format!("{value:?}"),
345 }
346 })?,
347 };
348
349 let no_fallback = match rule.get(serde_yaml::Value::String("no-fallback".to_string())) {
350 None | Some(serde_yaml::Value::Null) => false,
351 Some(serde_yaml::Value::Bool(value)) => *value,
352 Some(value) => {
353 return Err(ModelPolicyRuleParseError::NoFallbackMustBeBoolean {
354 found: format!("{value:?}"),
355 });
356 }
357 };
358
359 Ok(ModelPolicyRule {
360 match_type,
361 match_value,
362 no_fallback,
363 overrides,
364 })
365}
366
367impl<'de> Deserialize<'de> for ModelPolicyRule {
368 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
369 where
370 D: serde::Deserializer<'de>,
371 {
372 let value = serde_yaml::Value::deserialize(deserializer)?;
373 parse_model_policy_rule_value(&value)
374 .map_err(|err| serde::de::Error::custom(err.deserialize_message()))
375 }
376}
377
378impl Serialize for ModelPolicyRule {
379 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
380 where
381 S: serde::Serializer,
382 {
383 let mut map = serializer.serialize_map(None)?;
384 let match_key = match self.match_type {
385 ModelPolicyMatchType::Model => "model",
386 ModelPolicyMatchType::Alias => "alias",
387 ModelPolicyMatchType::ModelGlob => "model-glob",
388 };
389
390 let mut match_clause = serde_yaml::Mapping::new();
391 match_clause.insert(
392 serde_yaml::Value::String(match_key.to_string()),
393 serde_yaml::Value::String(self.match_value.clone()),
394 );
395 map.serialize_entry("match", &match_clause)?;
396 if !self.overrides.is_empty() {
397 map.serialize_entry("override", &self.overrides)?;
398 }
399 if self.no_fallback {
400 map.serialize_entry("no-fallback", &self.no_fallback)?;
401 }
402 map.end()
403 }
404}
405
406fn is_false(v: &bool) -> bool {
407 !v
408}
409
410#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
415pub struct LocalConfig {
416 #[serde(default)]
417 pub overrides: IndexMap<SourceName, OverrideEntry>,
418 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
419 pub agents: IndexMap<String, AgentOverlay>,
420 #[serde(default, skip_serializing_if = "LocalSettings::is_empty")]
421 pub settings: LocalSettings,
422}
423
424#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
425pub struct LocalSettings {
426 #[serde(default, rename = "model-policies")]
427 pub model_policies: Option<Vec<ModelPolicyRule>>,
428}
429
430impl LocalSettings {
431 fn is_empty(&self) -> bool {
432 self.model_policies.is_none()
433 }
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
438pub struct OverrideEntry {
439 #[serde(with = "toml_path_serde")]
440 pub path: PathBuf,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
445pub struct Settings {
446 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub managed_root: Option<String>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub targets: Option<Vec<String>>,
459 #[serde(default, skip_serializing_if = "ModelVisibility::is_empty")]
460 pub model_visibility: ModelVisibility,
461 #[serde(default = "default_models_cache_ttl_hours")]
462 pub models_cache_ttl_hours: u32,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub min_mars_version: Option<String>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub default_harness: Option<String>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub default_model: Option<String>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub harness_order: Option<Vec<String>>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub provider_order: Option<Vec<String>>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub agent_emission: Option<AgentEmission>,
494 #[serde(
495 default,
496 rename = "model-policies",
497 skip_serializing_if = "Vec::is_empty"
498 )]
499 pub model_policies: Vec<ModelPolicyRule>,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
503#[serde(rename_all = "lowercase")]
504pub enum AgentEmission {
505 Auto,
506 Always,
507 Never,
508}
509
510impl Default for Settings {
511 fn default() -> Self {
512 Self {
513 managed_root: None,
514 targets: None,
515 model_visibility: ModelVisibility::default(),
516 models_cache_ttl_hours: default_models_cache_ttl_hours(),
517 min_mars_version: None,
518 default_harness: None,
519 default_model: None,
520 harness_order: None,
521 provider_order: None,
522 agent_emission: None,
523 model_policies: Vec::new(),
524 }
525 }
526}
527
528fn default_models_cache_ttl_hours() -> u32 {
529 24
530}
531
532impl Settings {
533 pub fn effective_links(&self) -> targets::EffectiveLinks {
534 targets::effective_links(self.targets.as_deref(), self.managed_root.as_ref())
535 }
536
537 pub fn managed_targets(&self) -> Vec<String> {
545 self.effective_links().managed_targets()
546 }
547
548 pub fn linked_harnesses(&self) -> Vec<String> {
550 self.effective_links()
551 .linked_harnesses()
552 .into_iter()
553 .map(|harness| harness.to_string())
554 .collect()
555 }
556}
557
558#[derive(Debug, Clone)]
560pub enum SourceSpec {
561 Git(GitSpec),
562 Path(PathBuf),
563}
564
565#[derive(Debug, Clone)]
567pub struct GitSpec {
568 pub url: SourceUrl,
569 pub version: Option<String>,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
574pub enum FilterMode {
575 All,
577 Include {
579 agents: Vec<ItemName>,
580 skills: Vec<ItemName>,
581 },
582 Exclude(Vec<ItemName>),
584 OnlySkills,
586 OnlyAgents,
588}
589
590#[derive(Debug, Clone)]
594pub struct EffectiveConfig {
595 pub dependencies: IndexMap<SourceName, EffectiveDependency>,
596 pub settings: Settings,
597}
598
599#[derive(Debug, Clone)]
601pub struct EffectiveDependency {
602 pub name: SourceName,
603 pub id: SourceId,
604 pub spec: SourceSpec,
605 pub subpath: Option<SourceSubpath>,
606 pub filter: FilterMode,
607 pub rename: RenameMap,
608 pub is_overridden: bool,
609 pub original_git: Option<GitSpec>,
610}
611
612const CONFIG_FILE: &str = "mars.toml";
613const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
614
615pub fn load(root: &Path) -> Result<Config, MarsError> {
617 let path = root.join(CONFIG_FILE);
618 let content = std::fs::read_to_string(&path).map_err(|e| {
619 if e.kind() == std::io::ErrorKind::NotFound {
620 ConfigError::NotFound { path: path.clone() }
621 } else {
622 ConfigError::Io(e)
623 }
624 })?;
625 let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
626 migrate_legacy_source_urls(&mut config);
627 Ok(config)
628}
629
630pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
638 let path = source_root.join(CONFIG_FILE);
639 let diagnostics = Vec::new();
640 match std::fs::read_to_string(&path) {
641 Ok(content) => {
642 let parsed: Config =
643 toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
644 message: format!("failed to parse {}: {e}", path.display()),
645 })?;
646 let Some(package) = parsed.package else {
647 return Ok((None, diagnostics));
648 };
649 let deps: IndexMap<String, ManifestDep> = parsed
651 .dependencies
652 .into_iter()
653 .map(|(name, entry)| {
654 (
655 name.to_string(),
656 ManifestDep {
657 url: entry.url,
658 path: entry.path,
659 subpath: entry.subpath,
660 version: entry.version,
661 filter: entry.filter,
662 },
663 )
664 })
665 .collect();
666 Ok((
667 Some(Manifest {
668 package,
669 dependencies: deps,
670 models: parsed.models,
671 }),
672 diagnostics,
673 ))
674 }
675 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
676 Err(source) => Err(MarsError::Io {
677 operation: "read manifest config".to_string(),
678 path,
679 source,
680 }),
681 }
682}
683
684pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
686 let path = root.join(LOCAL_CONFIG_FILE);
687 match std::fs::read_to_string(&path) {
688 Ok(content) => {
689 let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
690 Ok(local)
691 }
692 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
693 Err(e) => Err(ConfigError::Io(e).into()),
694 }
695}
696
697pub fn merged_agent_overlays(
698 base: &IndexMap<String, AgentOverlay>,
699 local: &LocalConfig,
700) -> IndexMap<String, AgentOverlay> {
701 let mut merged = base.clone();
702 for (name, overlay) in &local.agents {
703 merged.insert(name.clone(), overlay.clone());
704 }
705 merged
706}
707
708pub fn merged_settings_model_policies(
709 settings: &Settings,
710 local: &LocalConfig,
711) -> Vec<ModelPolicyRule> {
712 local
713 .settings
714 .model_policies
715 .clone()
716 .unwrap_or_else(|| settings.model_policies.clone())
717}
718
719pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
726 let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
727 Ok(effective)
728}
729
730pub fn merge_with_root(
732 config: Config,
733 local: LocalConfig,
734 root: &Path,
735) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
736 config.settings.model_visibility.validate()?;
737 let mut dependencies = IndexMap::new();
738 let mut diagnostics = Vec::new();
739 let local_source_name = SourceOrigin::LocalPackage.to_string();
740
741 diagnostics.extend(deprecated_agents_target_diagnostics(&config.settings));
742
743 let all_deps = config
746 .dependencies
747 .iter()
748 .chain(config.local_dependencies.iter());
749
750 for (name, entry) in all_deps {
751 if name.as_ref() == local_source_name.as_str() {
753 return Err(ConfigError::Invalid {
754 message: "dependency name `_self` is reserved for local package items".into(),
755 }
756 .into());
757 }
758
759 if dependencies.contains_key(name) {
761 return Err(ConfigError::Invalid {
762 message: format!(
763 "dependency `{name}` appears in both [dependencies] and [local-dependencies]"
764 ),
765 }
766 .into());
767 }
768
769 let base_spec = match (&entry.url, &entry.path) {
771 (Some(url), None) => SourceSpec::Git(GitSpec {
772 url: url.clone(),
773 version: entry.version.clone(),
774 }),
775 (None, Some(path)) => SourceSpec::Path(path.clone()),
776 (Some(_), Some(_)) => {
777 return Err(ConfigError::Invalid {
778 message: format!("source `{name}` has both `url` and `path` — pick one"),
779 }
780 .into());
781 }
782 (None, None) => {
783 return Err(ConfigError::Invalid {
784 message: format!(
785 "source `{name}` has neither `url` nor `path` — one is required"
786 ),
787 }
788 .into());
789 }
790 };
791
792 validate_filter(&entry.filter, name.as_ref())?;
794
795 let filter = entry.filter.to_mode();
796
797 let rename = entry.filter.rename.clone().unwrap_or_default();
798
799 let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
801 let original = match &base_spec {
802 SourceSpec::Git(git) => Some(git.clone()),
803 SourceSpec::Path(_) => None,
804 };
805 (SourceSpec::Path(ov.path.clone()), true, original)
806 } else {
807 (base_spec, false, None)
808 };
809 let subpath = entry.subpath.clone();
810 let id = source_id_for_spec(root, &spec, subpath.clone());
811
812 dependencies.insert(
813 name.clone(),
814 EffectiveDependency {
815 name: name.clone(),
816 id,
817 spec,
818 subpath,
819 filter,
820 rename,
821 is_overridden,
822 original_git,
823 },
824 );
825 }
826
827 for override_name in local.overrides.keys() {
829 if !config.dependencies.contains_key(override_name) {
830 diagnostics.push(Diagnostic {
831 level: DiagnosticLevel::Warning,
832 code: "override-missing-dep",
833 message: format!(
834 "override `{override_name}` references a dependency not in mars.toml"
835 ),
836 context: None,
837 category: None,
838 });
839 }
840 }
841
842 Ok((
843 EffectiveConfig {
844 dependencies,
845 settings: config.settings,
846 },
847 diagnostics,
848 ))
849}
850
851fn deprecated_agents_target_diagnostics(settings: &Settings) -> Vec<Diagnostic> {
852 let mut diagnostics = Vec::new();
853
854 if settings.managed_root.as_deref() == Some(".agents") {
855 diagnostics.push(deprecated_agents_target_diagnostic("settings.managed_root"));
856 }
857
858 if settings
859 .targets
860 .as_ref()
861 .is_some_and(|targets| targets.iter().any(|target| target == ".agents"))
862 {
863 diagnostics.push(deprecated_agents_target_diagnostic("settings.targets"));
864 }
865
866 diagnostics
867}
868
869fn deprecated_agents_target_diagnostic(context: &str) -> Diagnostic {
870 Diagnostic {
871 level: DiagnosticLevel::Warning,
872 code: "deprecated-agents-target",
873 message: format!(
874 "`.agents` is a deprecated link target. Run `{}` to remove it. Skills are now emitted to native harness dirs automatically.",
875 managed_cmd("mars unlink .agents"),
876 ),
877 context: Some(context.to_string()),
878 category: Some(DiagnosticCategory::Compatibility),
879 }
880}
881
882pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
890 let has_include = filter.agents.is_some() || filter.skills.is_some();
891 let has_exclude = filter.exclude.is_some();
892 let has_category = filter.only_skills || filter.only_agents;
893
894 if filter.only_skills && filter.only_agents {
895 return Err(ConfigError::Invalid {
896 message: format!(
897 "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
898 ),
899 }
900 .into());
901 }
902 if has_category && has_include {
903 return Err(ConfigError::Invalid {
904 message: format!(
905 "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
906 ),
907 }
908 .into());
909 }
910 if has_category && has_exclude {
911 return Err(ConfigError::Invalid {
912 message: format!(
913 "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
914 ),
915 }
916 .into());
917 }
918 if has_include && has_exclude {
919 return Err(ConfigError::ConflictingFilters {
920 name: dep_name.to_string(),
921 }
922 .into());
923 }
924 Ok(())
925}
926
927impl FilterConfig {
928 pub fn to_mode(&self) -> FilterMode {
930 if self.only_skills {
931 FilterMode::OnlySkills
932 } else if self.only_agents {
933 FilterMode::OnlyAgents
934 } else if self.agents.is_some() || self.skills.is_some() {
935 FilterMode::Include {
936 agents: self.agents.clone().unwrap_or_default(),
937 skills: self.skills.clone().unwrap_or_default(),
938 }
939 } else if self.exclude.is_some() {
940 FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
941 } else {
942 FilterMode::All
943 }
944 }
945
946 pub fn has_any_filter(&self) -> bool {
948 self.agents.is_some()
949 || self.skills.is_some()
950 || self.exclude.is_some()
951 || self.only_skills
952 || self.only_agents
953 }
954}
955
956fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
957 match spec {
958 SourceSpec::Git(git) => {
959 let canonical_url = SourceUrl::from(crate::source::canonical::canonicalize_git_url(
960 git.url.as_ref(),
961 ));
962 SourceId::git_with_subpath(canonical_url, subpath.clone())
963 }
964 SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
965 Ok(id) => id,
966 Err(_) => {
967 let canonical = if path.is_absolute() {
968 path.clone()
969 } else {
970 root.join(path)
971 };
972 SourceId::Path { canonical, subpath }
973 }
974 },
975 }
976}
977
978fn migrate_legacy_source_urls(config: &mut Config) {
979 for dep in config
980 .dependencies
981 .values_mut()
982 .chain(config.local_dependencies.values_mut())
983 {
984 if let Some(url) = dep.url.as_mut() {
985 let raw = url.as_str();
986 if should_upgrade_legacy_git_url(raw) {
987 *url = SourceUrl::from(format!("https://{raw}"));
988 }
989 }
990 }
991}
992
993fn should_upgrade_legacy_git_url(url: &str) -> bool {
994 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
995}
996
997pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
999 let path = root.join(CONFIG_FILE);
1000 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
1001 message: format!("failed to serialize config: {e}"),
1002 })?;
1003 let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
1004 message: format!("refusing to save config: serialized output failed to parse: {e}"),
1005 })?;
1006 validate_save_roundtrip(config, &reparsed)?;
1007 crate::fs::atomic_write(&path, content.as_bytes())
1008}
1009
1010fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
1011 if reparsed.dependencies.len() != original.dependencies.len() {
1012 return Err(ConfigError::Invalid {
1013 message: format!(
1014 "refusing to save config: dependency count changed during roundtrip ({} -> {})",
1015 original.dependencies.len(),
1016 reparsed.dependencies.len()
1017 ),
1018 }
1019 .into());
1020 }
1021
1022 if reparsed.local_dependencies.len() != original.local_dependencies.len() {
1023 return Err(ConfigError::Invalid {
1024 message: format!(
1025 "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
1026 original.local_dependencies.len(),
1027 reparsed.local_dependencies.len()
1028 ),
1029 }
1030 .into());
1031 }
1032
1033 if reparsed.settings.managed_root != original.settings.managed_root {
1034 return Err(ConfigError::Invalid {
1035 message: format!(
1036 "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
1037 original.settings.managed_root, reparsed.settings.managed_root
1038 ),
1039 }
1040 .into());
1041 }
1042 if reparsed.settings.model_visibility != original.settings.model_visibility {
1043 return Err(ConfigError::Invalid {
1044 message: format!(
1045 "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
1046 original.settings.model_visibility, reparsed.settings.model_visibility
1047 ),
1048 }
1049 .into());
1050 }
1051 if reparsed.settings.default_harness != original.settings.default_harness {
1052 return Err(ConfigError::Invalid {
1053 message: format!(
1054 "refusing to save config: settings.default_harness changed during roundtrip ({:?} -> {:?})",
1055 original.settings.default_harness, reparsed.settings.default_harness
1056 ),
1057 }
1058 .into());
1059 }
1060 if reparsed.settings.default_model != original.settings.default_model {
1061 return Err(ConfigError::Invalid {
1062 message: format!(
1063 "refusing to save config: settings.default_model changed during roundtrip ({:?} -> {:?})",
1064 original.settings.default_model, reparsed.settings.default_model
1065 ),
1066 }
1067 .into());
1068 }
1069 if reparsed.settings.harness_order != original.settings.harness_order {
1070 return Err(ConfigError::Invalid {
1071 message: format!(
1072 "refusing to save config: settings.harness_order changed during roundtrip ({:?} -> {:?})",
1073 original.settings.harness_order, reparsed.settings.harness_order
1074 ),
1075 }
1076 .into());
1077 }
1078 if reparsed.settings.provider_order != original.settings.provider_order {
1079 return Err(ConfigError::Invalid {
1080 message: format!(
1081 "refusing to save config: settings.provider_order changed during roundtrip ({:?} -> {:?})",
1082 original.settings.provider_order, reparsed.settings.provider_order
1083 ),
1084 }
1085 .into());
1086 }
1087 if reparsed.settings.agent_emission != original.settings.agent_emission {
1088 return Err(ConfigError::Invalid {
1089 message: format!(
1090 "refusing to save config: settings.agent_emission changed during roundtrip ({:?} -> {:?})",
1091 original.settings.agent_emission, reparsed.settings.agent_emission
1092 ),
1093 }
1094 .into());
1095 }
1096 if reparsed.settings.model_policies != original.settings.model_policies {
1097 return Err(ConfigError::Invalid {
1098 message: "refusing to save config: settings.model_policies changed during roundtrip"
1099 .to_string(),
1100 }
1101 .into());
1102 }
1103 if reparsed.agents != original.agents {
1104 return Err(ConfigError::Invalid {
1105 message: "refusing to save config: agents changed during roundtrip".to_string(),
1106 }
1107 .into());
1108 }
1109
1110 for (name, dep) in &original.dependencies {
1111 let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
1112 return Err(ConfigError::Invalid {
1113 message: format!(
1114 "refusing to save config: dependency `{name}` missing after roundtrip"
1115 ),
1116 }
1117 .into());
1118 };
1119
1120 if reparsed_dep != dep {
1121 return Err(ConfigError::Invalid {
1122 message: format!(
1123 "refusing to save config: dependency `{name}` changed during roundtrip"
1124 ),
1125 }
1126 .into());
1127 }
1128 }
1129
1130 for (name, dep) in &original.local_dependencies {
1131 let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
1132 return Err(ConfigError::Invalid {
1133 message: format!(
1134 "refusing to save config: local-dependency `{name}` missing after roundtrip"
1135 ),
1136 }
1137 .into());
1138 };
1139
1140 if reparsed_dep != dep {
1141 return Err(ConfigError::Invalid {
1142 message: format!(
1143 "refusing to save config: local-dependency `{name}` changed during roundtrip"
1144 ),
1145 }
1146 .into());
1147 }
1148 }
1149
1150 Ok(())
1151}
1152
1153pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
1155 let path = root.join(LOCAL_CONFIG_FILE);
1156 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
1157 message: format!("failed to serialize local config: {e}"),
1158 })?;
1159 crate::fs::atomic_write(&path, content.as_bytes())
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165 use tempfile::TempDir;
1166
1167 #[test]
1168 fn parse_git_dependency() {
1169 let toml_str = r#"
1170[dependencies.base]
1171url = "https://github.com/org/base.git"
1172version = "v1.0"
1173"#;
1174 let config: Config = toml::from_str(toml_str).unwrap();
1175 assert_eq!(config.dependencies.len(), 1);
1176 let entry = &config.dependencies["base"];
1177 assert_eq!(
1178 entry.url.as_deref(),
1179 Some("https://github.com/org/base.git")
1180 );
1181 assert!(entry.path.is_none());
1182 assert_eq!(entry.version.as_deref(), Some("v1.0"));
1183 }
1184
1185 #[test]
1186 fn parse_path_dependency() {
1187 let toml_str = r#"
1188[dependencies.local]
1189path = "../my-agents"
1190"#;
1191 let config: Config = toml::from_str(toml_str).unwrap();
1192 let entry = &config.dependencies["local"];
1193 assert!(entry.url.is_none());
1194 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
1195 }
1196
1197 #[test]
1198 fn parse_mixed_dependencies() {
1199 let toml_str = r#"
1200[dependencies.remote]
1201url = "https://github.com/org/remote.git"
1202version = "v2.0"
1203agents = ["coder", "reviewer"]
1204
1205[dependencies.local]
1206path = "/home/dev/agents"
1207exclude = ["experimental"]
1208"#;
1209 let config: Config = toml::from_str(toml_str).unwrap();
1210 assert_eq!(config.dependencies.len(), 2);
1211 assert!(config.dependencies.contains_key("remote"));
1212 assert!(config.dependencies.contains_key("local"));
1213 }
1214
1215 #[test]
1216 fn parse_package_and_dependencies_coexist() {
1217 let toml_str = r#"
1218[package]
1219name = "my-agents"
1220version = "0.1.0"
1221
1222[dependencies.base]
1223url = "https://github.com/org/base.git"
1224version = ">=1.0.0"
1225
1226[dependencies.local]
1227path = "../local-agents"
1228"#;
1229 let config: Config = toml::from_str(toml_str).unwrap();
1230 assert!(config.package.is_some());
1231 assert!(config.dependencies.contains_key("base"));
1232 assert!(config.dependencies.contains_key("local"));
1233 }
1234
1235 #[test]
1236 fn parse_include_filter() {
1237 let toml_str = r#"
1238[dependencies.base]
1239url = "https://github.com/org/base.git"
1240agents = ["coder"]
1241skills = ["review"]
1242"#;
1243 let config: Config = toml::from_str(toml_str).unwrap();
1244 let local = LocalConfig::default();
1245 let effective = merge(config, local).unwrap();
1246 let source = &effective.dependencies["base"];
1247 match &source.filter {
1248 FilterMode::Include { agents, skills } => {
1249 assert_eq!(agents, &["coder"]);
1250 assert_eq!(skills, &["review"]);
1251 }
1252 other => panic!("expected Include, got {other:?}"),
1253 }
1254 }
1255
1256 #[test]
1257 fn parse_exclude_filter() {
1258 let toml_str = r#"
1259[dependencies.base]
1260url = "https://github.com/org/base.git"
1261exclude = ["experimental", "deprecated"]
1262"#;
1263 let config: Config = toml::from_str(toml_str).unwrap();
1264 let local = LocalConfig::default();
1265 let effective = merge(config, local).unwrap();
1266 let source = &effective.dependencies["base"];
1267 match &source.filter {
1268 FilterMode::Exclude(items) => {
1269 assert_eq!(items, &["experimental", "deprecated"]);
1270 }
1271 other => panic!("expected Exclude, got {other:?}"),
1272 }
1273 }
1274
1275 #[test]
1276 fn error_on_both_include_and_exclude() {
1277 let toml_str = r#"
1278[dependencies.bad]
1279url = "https://github.com/org/bad.git"
1280agents = ["coder"]
1281exclude = ["reviewer"]
1282"#;
1283 let config: Config = toml::from_str(toml_str).unwrap();
1284 let local = LocalConfig::default();
1285 let result = merge(config, local);
1286 assert!(result.is_err());
1287 let err = result.unwrap_err().to_string();
1288 assert!(
1289 err.contains("bad"),
1290 "error should mention dependency name: {err}"
1291 );
1292 }
1293
1294 #[test]
1295 fn error_on_neither_url_nor_path() {
1296 let toml_str = r#"
1297[dependencies.empty]
1298version = "v1.0"
1299"#;
1300 let config: Config = toml::from_str(toml_str).unwrap();
1301 let local = LocalConfig::default();
1302 let result = merge(config, local);
1303 assert!(result.is_err());
1304 let err = result.unwrap_err().to_string();
1305 assert!(
1306 err.contains("neither"),
1307 "error should mention 'neither': {err}"
1308 );
1309 }
1310
1311 #[test]
1312 fn error_on_both_url_and_path() {
1313 let toml_str = r#"
1314[dependencies.both]
1315url = "https://github.com/org/repo.git"
1316path = "/local/path"
1317"#;
1318 let config: Config = toml::from_str(toml_str).unwrap();
1319 let local = LocalConfig::default();
1320 let result = merge(config, local);
1321 assert!(result.is_err());
1322 let err = result.unwrap_err().to_string();
1323 assert!(err.contains("both"), "error should mention 'both': {err}");
1324 }
1325
1326 #[test]
1327 fn roundtrip_full_config_shape_survives_save() {
1328 let dir = TempDir::new().unwrap();
1329 let original = r#"
1330[package]
1331name = "sample"
1332version = "0.1.0"
1333description = "sample package"
1334
1335[dependencies.base]
1336url = "https://github.com/org/base.git"
1337version = "v1.0"
1338agents = ["coder", "reviewer"]
1339
1340[dependencies.local]
1341path = "../local-agents"
1342exclude = ["experimental"]
1343
1344[settings]
1345managed_root = ".custom-agents"
1346targets = [".claude", ".cursor"]
1347harness_order = ["pi", "opencode", "codex"]
1348"#;
1349 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1350
1351 let config = load(dir.path()).unwrap();
1352 save(dir.path(), &config).unwrap();
1353 let reloaded = load(dir.path()).unwrap();
1354
1355 assert_eq!(
1356 reloaded.package.as_ref().map(|p| p.name.as_str()),
1357 Some("sample")
1358 );
1359 assert_eq!(reloaded.dependencies.len(), 2);
1360 assert_eq!(
1361 reloaded.dependencies["base"].url.as_deref(),
1362 Some("https://github.com/org/base.git")
1363 );
1364 assert_eq!(
1365 reloaded.dependencies["local"].path.as_deref(),
1366 Some(Path::new("../local-agents"))
1367 );
1368 assert_eq!(
1369 reloaded.settings.managed_root.as_deref(),
1370 Some(".custom-agents")
1371 );
1372 assert_eq!(
1373 reloaded.settings.targets,
1374 Some(vec![".claude".to_string(), ".cursor".to_string()])
1375 );
1376 assert_eq!(
1377 reloaded.settings.harness_order,
1378 Some(vec![
1379 "pi".to_string(),
1380 "opencode".to_string(),
1381 "codex".to_string()
1382 ])
1383 );
1384 }
1385
1386 #[test]
1387 fn load_from_disk() {
1388 let dir = TempDir::new().unwrap();
1389 let toml_str = r#"
1390[dependencies.base]
1391url = "https://github.com/org/base.git"
1392version = "v1.0"
1393"#;
1394 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1395 let config = load(dir.path()).unwrap();
1396 assert_eq!(config.dependencies.len(), 1);
1397 }
1398
1399 #[test]
1400 fn load_migrates_legacy_bare_domain_url() {
1401 let dir = TempDir::new().unwrap();
1402 let toml_str = r#"
1403[dependencies.base]
1404url = "github.com/org/base"
1405"#;
1406 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1407
1408 let config = load(dir.path()).unwrap();
1409 assert_eq!(
1410 config.dependencies["base"].url.as_deref(),
1411 Some("https://github.com/org/base")
1412 );
1413 }
1414
1415 #[test]
1416 fn load_does_not_migrate_ssh_url() {
1417 let dir = TempDir::new().unwrap();
1418 let toml_str = r#"
1419[dependencies.base]
1420url = "git@github.com:org/base.git"
1421"#;
1422 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1423
1424 let config = load(dir.path()).unwrap();
1425 assert_eq!(
1426 config.dependencies["base"].url.as_deref(),
1427 Some("git@github.com:org/base.git")
1428 );
1429 }
1430
1431 #[test]
1432 fn load_missing_file_returns_not_found() {
1433 let dir = TempDir::new().unwrap();
1434 let result = load(dir.path());
1435 assert!(result.is_err());
1436 let err = result.unwrap_err().to_string();
1437 assert!(err.contains("not found"), "should be NotFound: {err}");
1438 }
1439
1440 #[test]
1441 fn load_manifest_returns_none_without_package() {
1442 let dir = TempDir::new().unwrap();
1443 std::fs::write(
1444 dir.path().join("mars.toml"),
1445 r#"
1446[dependencies.base]
1447url = "https://github.com/org/base.git"
1448"#,
1449 )
1450 .unwrap();
1451
1452 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1453 assert!(diagnostics.is_empty());
1454 assert!(manifest.is_none());
1455 }
1456
1457 #[test]
1458 fn load_manifest_returns_package_and_dependencies() {
1459 let dir = TempDir::new().unwrap();
1460 std::fs::write(
1461 dir.path().join("mars.toml"),
1462 r#"
1463[package]
1464name = "pkg"
1465version = "1.2.3"
1466
1467[dependencies.base]
1468url = "https://github.com/org/base.git"
1469version = ">=1.0.0"
1470skills = ["frontend-design"]
1471"#,
1472 )
1473 .unwrap();
1474
1475 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1476 assert!(diagnostics.is_empty());
1477 let manifest = manifest.unwrap();
1478 assert_eq!(manifest.package.name, "pkg");
1479 assert_eq!(manifest.package.version, "1.2.3");
1480 assert!(manifest.dependencies.contains_key("base"));
1481 assert_eq!(
1482 manifest.dependencies["base"].filter.skills.as_deref(),
1483 Some(&[ItemName::from("frontend-design")][..])
1484 );
1485 }
1486
1487 #[test]
1488 fn load_manifest_io_error_includes_operation_and_path() {
1489 let dir = TempDir::new().unwrap();
1490 let config_path = dir.path().join("mars.toml");
1491 std::fs::create_dir(&config_path).unwrap();
1492
1493 let err = load_manifest(dir.path()).unwrap_err();
1494 let msg = err.to_string();
1495
1496 assert!(
1497 msg.contains("read manifest config"),
1498 "error should include operation context: {msg}"
1499 );
1500 assert!(
1501 msg.contains("mars.toml"),
1502 "error should include config path: {msg}"
1503 );
1504 }
1505
1506 #[test]
1507 fn load_local_missing_returns_default() {
1508 let dir = TempDir::new().unwrap();
1509 let local = load_local(dir.path()).unwrap();
1510 assert!(local.overrides.is_empty());
1511 }
1512
1513 #[test]
1514 fn load_local_from_disk() {
1515 let dir = TempDir::new().unwrap();
1516 let toml_str = r#"
1517[overrides.base]
1518path = "/home/dev/local-base"
1519"#;
1520 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1521 let local = load_local(dir.path()).unwrap();
1522 assert_eq!(local.overrides.len(), 1);
1523 assert_eq!(
1524 local.overrides["base"].path,
1525 PathBuf::from("/home/dev/local-base")
1526 );
1527 }
1528
1529 #[test]
1530 fn parse_agent_overlay_and_settings_model_policies() {
1531 let config: Config = toml::from_str(
1532 r#"
1533[agents.tech-lead]
1534model = "gpt55"
1535harness = "codex"
1536effort = "medium"
1537approval = "default"
1538sandbox = "default"
1539autocompact = 1200
1540autocompact_pct = 80
1541
1542[[agents.tech-lead.model-policies]]
1543match = { alias = "gpt55" }
1544override = { harness = "opencode", effort = "low" }
1545no-fallback = true
1546
1547[settings]
1548
1549[[settings.model-policies]]
1550match = { model-glob = "gpt-*" }
1551override = { effort = "high" }
1552"#,
1553 )
1554 .unwrap();
1555
1556 let overlay = config.agents.get("tech-lead").expect("tech-lead overlay");
1557 assert_eq!(overlay.model.as_deref(), Some("gpt55"));
1558 assert_eq!(overlay.harness.as_deref(), Some("codex"));
1559 assert_eq!(overlay.autocompact, Some(1200));
1560 assert_eq!(overlay.autocompact_pct, Some(80));
1561 assert_eq!(overlay.model_policies.len(), 1);
1562 assert_eq!(
1563 overlay.model_policies[0].match_type,
1564 ModelPolicyMatchType::Alias
1565 );
1566 assert_eq!(overlay.model_policies[0].match_value, "gpt55");
1567 assert!(overlay.model_policies[0].no_fallback);
1568
1569 assert_eq!(config.settings.model_policies.len(), 1);
1570 assert_eq!(
1571 config.settings.model_policies[0].match_type,
1572 ModelPolicyMatchType::ModelGlob
1573 );
1574 assert_eq!(config.settings.model_policies[0].match_value, "gpt-*");
1575 }
1576
1577 #[test]
1578 fn merged_agent_overlays_replace_by_agent_name() {
1579 let mut base_agents = IndexMap::new();
1580 base_agents.insert(
1581 "tech-lead".to_string(),
1582 AgentOverlay {
1583 model: Some("gpt55".to_string()),
1584 harness: Some("codex".to_string()),
1585 effort: Some("high".to_string()),
1586 ..AgentOverlay::default()
1587 },
1588 );
1589 base_agents.insert(
1590 "reviewer".to_string(),
1591 AgentOverlay {
1592 model: Some("gpt-5.4-mini".to_string()),
1593 ..AgentOverlay::default()
1594 },
1595 );
1596
1597 let mut local_agents = IndexMap::new();
1598 local_agents.insert(
1599 "tech-lead".to_string(),
1600 AgentOverlay {
1601 model: Some("gptmini".to_string()),
1602 ..AgentOverlay::default()
1603 },
1604 );
1605 let local = LocalConfig {
1606 agents: local_agents,
1607 ..LocalConfig::default()
1608 };
1609
1610 let merged = merged_agent_overlays(&base_agents, &local);
1611 let replaced = merged.get("tech-lead").expect("tech-lead should exist");
1612 assert_eq!(replaced.model.as_deref(), Some("gptmini"));
1613 assert!(
1614 replaced.harness.is_none(),
1615 "local overlay must replace the base overlay block"
1616 );
1617 assert!(
1618 replaced.effort.is_none(),
1619 "local overlay replacement must not deep-merge base fields"
1620 );
1621 assert_eq!(
1622 merged
1623 .get("reviewer")
1624 .and_then(|overlay| overlay.model.as_deref()),
1625 Some("gpt-5.4-mini")
1626 );
1627 }
1628
1629 #[test]
1630 fn merged_settings_model_policies_use_local_replacement_when_present() {
1631 let mut base_override = serde_yaml::Mapping::new();
1632 base_override.insert(
1633 serde_yaml::Value::String("harness".to_string()),
1634 serde_yaml::Value::String("codex".to_string()),
1635 );
1636 let base_rule = ModelPolicyRule {
1637 match_type: ModelPolicyMatchType::Alias,
1638 match_value: "gpt55".to_string(),
1639 no_fallback: false,
1640 overrides: base_override,
1641 };
1642
1643 let mut local_override = serde_yaml::Mapping::new();
1644 local_override.insert(
1645 serde_yaml::Value::String("harness".to_string()),
1646 serde_yaml::Value::String("opencode".to_string()),
1647 );
1648 let local_rule = ModelPolicyRule {
1649 match_type: ModelPolicyMatchType::Alias,
1650 match_value: "gpt55".to_string(),
1651 no_fallback: false,
1652 overrides: local_override,
1653 };
1654
1655 let settings = Settings {
1656 model_policies: vec![base_rule],
1657 ..Settings::default()
1658 };
1659 let local = LocalConfig {
1660 settings: LocalSettings {
1661 model_policies: Some(vec![local_rule.clone()]),
1662 },
1663 ..LocalConfig::default()
1664 };
1665
1666 let merged = merged_settings_model_policies(&settings, &local);
1667 assert_eq!(merged, vec![local_rule]);
1668 }
1669
1670 #[test]
1671 fn merge_with_empty_local() {
1672 let config = Config {
1673 dependencies: {
1674 let mut m = IndexMap::new();
1675 m.insert(
1676 "base".into(),
1677 DependencyEntry {
1678 url: Some("https://github.com/org/base.git".into()),
1679 path: None,
1680 subpath: None,
1681 version: Some("v1.0".into()),
1682 filter: FilterConfig::default(),
1683 },
1684 );
1685 m
1686 },
1687 settings: Settings::default(),
1688 ..Config::default()
1689 };
1690 let local = LocalConfig::default();
1691 let effective = merge(config, local).unwrap();
1692 assert_eq!(effective.dependencies.len(), 1);
1693 let source = &effective.dependencies["base"];
1694 assert!(!source.is_overridden);
1695 assert!(source.original_git.is_none());
1696 match &source.spec {
1697 SourceSpec::Git(git) => {
1698 assert_eq!(git.url, "https://github.com/org/base.git");
1699 assert_eq!(git.version.as_deref(), Some("v1.0"));
1700 }
1701 SourceSpec::Path(_) => panic!("expected Git"),
1702 }
1703 }
1704
1705 #[test]
1706 fn merge_override_replaces_with_path() {
1707 let config = Config {
1708 dependencies: {
1709 let mut m = IndexMap::new();
1710 m.insert(
1711 "base".into(),
1712 DependencyEntry {
1713 url: Some("https://github.com/org/base.git".into()),
1714 path: None,
1715 subpath: None,
1716 version: Some("v1.0".into()),
1717 filter: FilterConfig::default(),
1718 },
1719 );
1720 m
1721 },
1722 settings: Settings::default(),
1723 ..Config::default()
1724 };
1725 let local = LocalConfig {
1726 overrides: {
1727 let mut m = IndexMap::new();
1728 m.insert(
1729 "base".into(),
1730 OverrideEntry {
1731 path: PathBuf::from("/home/dev/local-base"),
1732 },
1733 );
1734 m
1735 },
1736 ..LocalConfig::default()
1737 };
1738 let effective = merge(config, local).unwrap();
1739 let source = &effective.dependencies["base"];
1740 assert!(source.is_overridden);
1741
1742 match &source.spec {
1743 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
1744 SourceSpec::Git(_) => panic!("expected Path override"),
1745 }
1746
1747 let orig = source.original_git.as_ref().unwrap();
1748 assert_eq!(orig.url, "https://github.com/org/base.git");
1749 assert_eq!(orig.version.as_deref(), Some("v1.0"));
1750 }
1751
1752 #[test]
1753 fn merge_override_retains_subpath_coordinate() {
1754 let temp = TempDir::new().unwrap();
1755 let temp_root = dunce::canonicalize(temp.path()).unwrap();
1757 let override_path = temp_root.join("local-base");
1758 std::fs::create_dir_all(&override_path).unwrap();
1759 let canonical_override = dunce::canonicalize(&override_path).unwrap();
1760
1761 let config = Config {
1762 dependencies: {
1763 let mut m = IndexMap::new();
1764 m.insert(
1765 "base".into(),
1766 DependencyEntry {
1767 url: Some("https://github.com/org/base.git".into()),
1768 path: None,
1769 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1770 version: Some("v1.0".into()),
1771 filter: FilterConfig::default(),
1772 },
1773 );
1774 m
1775 },
1776 settings: Settings::default(),
1777 ..Config::default()
1778 };
1779 let local = LocalConfig {
1780 overrides: {
1781 let mut m = IndexMap::new();
1782 m.insert(
1783 "base".into(),
1784 OverrideEntry {
1785 path: canonical_override.clone(),
1786 },
1787 );
1788 m
1789 },
1790 ..LocalConfig::default()
1791 };
1792
1793 let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
1794 let source = &effective.dependencies["base"];
1795 assert!(source.is_overridden);
1796 assert_eq!(
1797 source.subpath.as_ref().map(SourceSubpath::as_str),
1798 Some("plugins/foo")
1799 );
1800 assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
1801 assert!(matches!(
1802 &source.id,
1803 SourceId::Path {
1804 canonical,
1805 subpath: Some(sp)
1806 } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
1807 ));
1808 }
1809
1810 #[test]
1811 fn merge_all_filter_mode() {
1812 let config = Config {
1813 dependencies: {
1814 let mut m = IndexMap::new();
1815 m.insert(
1816 "base".into(),
1817 DependencyEntry {
1818 url: Some("https://github.com/org/base.git".into()),
1819 path: None,
1820 subpath: None,
1821 version: None,
1822 filter: FilterConfig::default(),
1823 },
1824 );
1825 m
1826 },
1827 settings: Settings::default(),
1828 ..Config::default()
1829 };
1830 let effective = merge(config, LocalConfig::default()).unwrap();
1831 assert!(matches!(
1832 effective.dependencies["base"].filter,
1833 FilterMode::All
1834 ));
1835 }
1836
1837 #[test]
1838 fn save_and_reload() {
1839 let dir = TempDir::new().unwrap();
1840 let config = Config {
1841 dependencies: {
1842 let mut m = IndexMap::new();
1843 m.insert(
1844 "base".into(),
1845 DependencyEntry {
1846 url: Some("https://github.com/org/base.git".into()),
1847 path: None,
1848 subpath: None,
1849 version: Some("v2.0".into()),
1850 filter: FilterConfig::default(),
1851 },
1852 );
1853 m
1854 },
1855 settings: Settings::default(),
1856 ..Config::default()
1857 };
1858 save(dir.path(), &config).unwrap();
1859 let reloaded = load(dir.path()).unwrap();
1860 assert_eq!(config, reloaded);
1861 }
1862
1863 #[test]
1864 fn rename_map_preserved() {
1865 let toml_str = r#"
1866[dependencies.base]
1867url = "https://github.com/org/base.git"
1868
1869[dependencies.base.rename]
1870old-name = "new-name"
1871"#;
1872 let config: Config = toml::from_str(toml_str).unwrap();
1873 let effective = merge(config, LocalConfig::default()).unwrap();
1874 let source = &effective.dependencies["base"];
1875 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1876 }
1877
1878 #[test]
1879 fn self_dependency_name_rejected() {
1880 let toml_str = r#"
1881[dependencies._self]
1882url = "https://github.com/org/base.git"
1883"#;
1884 let config: Config = toml::from_str(toml_str).unwrap();
1885 let local = LocalConfig::default();
1886 let result = merge(config, local);
1887 assert!(result.is_err());
1888 let err = result.unwrap_err().to_string();
1889 assert!(
1890 err.contains("_self") && err.contains("reserved"),
1891 "should reject _self: {err}"
1892 );
1893 }
1894
1895 #[test]
1896 fn managed_root_setting_roundtrip() {
1897 let config = Config {
1898 settings: Settings {
1899 managed_root: Some(".claude".into()),
1900 targets: None,
1901 ..Settings::default()
1902 },
1903 ..Config::default()
1904 };
1905 let serialized = toml::to_string_pretty(&config).unwrap();
1906 let deserialized: Config = toml::from_str(&serialized).unwrap();
1907 assert_eq!(
1908 deserialized.settings.managed_root.as_deref(),
1909 Some(".claude")
1910 );
1911 }
1912
1913 #[test]
1914 fn save_preserves_dependencies_when_clearing_last_target() {
1915 let dir = TempDir::new().unwrap();
1916 let original = r#"
1917[package]
1918name = "sample"
1919version = "0.1.0"
1920
1921[dependencies.base]
1922url = "https://github.com/org/base.git"
1923version = "v1.0"
1924agents = ["coder"]
1925
1926[settings]
1927managed_root = ".agents"
1928targets = [".claude"]
1929"#;
1930 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1931
1932 let mut config = load(dir.path()).unwrap();
1933 if let Some(targets) = config.settings.targets.as_mut() {
1934 targets.retain(|target| target != ".claude");
1935 if targets.is_empty() {
1936 config.settings.targets = None;
1937 }
1938 }
1939 save(dir.path(), &config).unwrap();
1940
1941 let reloaded = load(dir.path()).unwrap();
1942 assert_eq!(
1943 reloaded.package.as_ref().map(|p| p.name.as_str()),
1944 Some("sample")
1945 );
1946 assert_eq!(
1947 reloaded.dependencies["base"].url.as_deref(),
1948 Some("https://github.com/org/base.git")
1949 );
1950 assert_eq!(
1951 reloaded.dependencies["base"].version.as_deref(),
1952 Some("v1.0")
1953 );
1954 assert_eq!(
1955 reloaded.dependencies["base"].filter.agents.as_deref(),
1956 Some(&["coder".into()][..])
1957 );
1958 assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1959 assert!(reloaded.settings.targets.is_none());
1960 }
1961
1962 #[test]
1963 fn roundtrip_preserves_all_filter_fields() {
1964 let dir = TempDir::new().unwrap();
1965 let original = r#"
1966[dependencies.include]
1967url = "https://github.com/org/include.git"
1968agents = ["coder", "reviewer"]
1969skills = ["review", "plan"]
1970
1971[dependencies.include.rename]
1972coder = "core-coder"
1973
1974[dependencies.exclude]
1975url = "https://github.com/org/exclude.git"
1976exclude = ["experimental", "deprecated"]
1977
1978[dependencies.only_skills]
1979url = "https://github.com/org/skills.git"
1980only_skills = true
1981
1982[dependencies.only_agents]
1983url = "https://github.com/org/agents.git"
1984only_agents = true
1985"#;
1986 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1987
1988 let config = load(dir.path()).unwrap();
1989 save(dir.path(), &config).unwrap();
1990 let reloaded = load(dir.path()).unwrap();
1991
1992 let include = &reloaded.dependencies["include"].filter;
1993 assert_eq!(
1994 include.agents.as_deref(),
1995 Some(&["coder".into(), "reviewer".into()][..])
1996 );
1997 assert_eq!(
1998 include.skills.as_deref(),
1999 Some(&["review".into(), "plan".into()][..])
2000 );
2001 assert_eq!(
2002 include.rename.as_ref().and_then(|r| r.get("coder")),
2003 Some(&"core-coder".into())
2004 );
2005
2006 let exclude = &reloaded.dependencies["exclude"].filter;
2007 assert_eq!(
2008 exclude.exclude.as_deref(),
2009 Some(&["experimental".into(), "deprecated".into()][..])
2010 );
2011
2012 let only_skills = &reloaded.dependencies["only_skills"].filter;
2013 assert!(only_skills.only_skills);
2014 assert!(!only_skills.only_agents);
2015
2016 let only_agents = &reloaded.dependencies["only_agents"].filter;
2017 assert!(only_agents.only_agents);
2018 assert!(!only_agents.only_skills);
2019 }
2020
2021 #[test]
2022 fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
2023 let dir = TempDir::new().unwrap();
2024 let original = r#"
2025[dependencies.git-include]
2026url = "https://github.com/org/git-include.git"
2027agents = ["coder"]
2028
2029[dependencies.path-exclude]
2030path = "../local-source"
2031exclude = ["draft"]
2032
2033[dependencies.git-only-skills]
2034url = "https://github.com/org/git-skills.git"
2035only_skills = true
2036
2037[dependencies.git-only-agents]
2038url = "https://github.com/org/git-agents.git"
2039only_agents = true
2040"#;
2041 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2042
2043 let config = load(dir.path()).unwrap();
2044 save(dir.path(), &config).unwrap();
2045 let reloaded = load(dir.path()).unwrap();
2046
2047 assert_eq!(reloaded.dependencies.len(), 4);
2048 assert_eq!(
2049 reloaded.dependencies["git-include"]
2050 .filter
2051 .agents
2052 .as_deref(),
2053 Some(&["coder".into()][..])
2054 );
2055 assert_eq!(
2056 reloaded.dependencies["path-exclude"].path.as_deref(),
2057 Some(Path::new("../local-source"))
2058 );
2059 assert_eq!(
2060 reloaded.dependencies["path-exclude"]
2061 .filter
2062 .exclude
2063 .as_deref(),
2064 Some(&["draft".into()][..])
2065 );
2066 assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
2067 assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
2068 }
2069
2070 #[test]
2071 fn save_roundtrip_guard_rejects_dependency_count_loss() {
2072 let mut original = Config::default();
2073 original.dependencies.insert(
2074 "base".into(),
2075 DependencyEntry {
2076 url: Some("https://github.com/org/base.git".into()),
2077 path: None,
2078 subpath: None,
2079 version: Some("v1.0".into()),
2080 filter: FilterConfig::default(),
2081 },
2082 );
2083
2084 let reparsed = Config::default();
2085 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2086 let msg = err.to_string();
2087 assert!(
2088 msg.contains("dependency count changed"),
2089 "unexpected error: {msg}"
2090 );
2091 }
2092
2093 #[test]
2094 fn save_roundtrip_guard_rejects_managed_root_loss() {
2095 let original = Config {
2096 settings: Settings {
2097 managed_root: Some(".agents".into()),
2098 targets: None,
2099 ..Settings::default()
2100 },
2101 ..Config::default()
2102 };
2103 let reparsed = Config::default();
2104 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2105 let msg = err.to_string();
2106 assert!(
2107 msg.contains("settings.managed_root changed"),
2108 "unexpected error: {msg}"
2109 );
2110 }
2111
2112 #[test]
2113 fn save_roundtrip_guard_rejects_harness_order_loss() {
2114 let original = Config {
2115 settings: Settings {
2116 harness_order: Some(vec!["pi".into(), "codex".into()]),
2117 ..Settings::default()
2118 },
2119 ..Config::default()
2120 };
2121 let reparsed = Config::default();
2122 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2123 let msg = err.to_string();
2124 assert!(
2125 msg.contains("settings.harness_order changed"),
2126 "unexpected error: {msg}"
2127 );
2128 }
2129
2130 #[test]
2131 fn parse_only_skills_filter() {
2132 let toml_str = r#"
2133[dependencies.base]
2134url = "https://github.com/org/base.git"
2135only_skills = true
2136"#;
2137 let config: Config = toml::from_str(toml_str).unwrap();
2138 let local = LocalConfig::default();
2139 let effective = merge(config, local).unwrap();
2140 let source = &effective.dependencies["base"];
2141 assert!(matches!(source.filter, FilterMode::OnlySkills));
2142 }
2143
2144 #[test]
2145 fn parse_only_agents_filter() {
2146 let toml_str = r#"
2147[dependencies.base]
2148url = "https://github.com/org/base.git"
2149only_agents = true
2150"#;
2151 let config: Config = toml::from_str(toml_str).unwrap();
2152 let local = LocalConfig::default();
2153 let effective = merge(config, local).unwrap();
2154 let source = &effective.dependencies["base"];
2155 assert!(matches!(source.filter, FilterMode::OnlyAgents));
2156 }
2157
2158 #[test]
2159 fn error_on_only_skills_and_only_agents() {
2160 let toml_str = r#"
2161[dependencies.bad]
2162url = "https://github.com/org/bad.git"
2163only_skills = true
2164only_agents = true
2165"#;
2166 let config: Config = toml::from_str(toml_str).unwrap();
2167 let local = LocalConfig::default();
2168 let result = merge(config, local);
2169 assert!(result.is_err());
2170 let err = result.unwrap_err().to_string();
2171 assert!(
2172 err.contains("mutually exclusive"),
2173 "should mention mutually exclusive: {err}"
2174 );
2175 }
2176
2177 #[test]
2178 fn error_on_only_skills_with_agents_list() {
2179 let toml_str = r#"
2180[dependencies.bad]
2181url = "https://github.com/org/bad.git"
2182only_skills = true
2183agents = ["coder"]
2184"#;
2185 let config: Config = toml::from_str(toml_str).unwrap();
2186 let local = LocalConfig::default();
2187 let result = merge(config, local);
2188 assert!(result.is_err());
2189 let err = result.unwrap_err().to_string();
2190 assert!(
2191 err.contains("cannot combine"),
2192 "should mention cannot combine: {err}"
2193 );
2194 }
2195
2196 #[test]
2197 fn error_on_only_agents_with_skills_list() {
2198 let toml_str = r#"
2199[dependencies.bad]
2200url = "https://github.com/org/bad.git"
2201only_agents = true
2202skills = ["planning"]
2203"#;
2204 let config: Config = toml::from_str(toml_str).unwrap();
2205 let local = LocalConfig::default();
2206 let result = merge(config, local);
2207 assert!(result.is_err());
2208 }
2209
2210 #[test]
2211 fn error_on_only_skills_with_exclude() {
2212 let toml_str = r#"
2213[dependencies.bad]
2214url = "https://github.com/org/bad.git"
2215only_skills = true
2216exclude = ["deprecated"]
2217"#;
2218 let config: Config = toml::from_str(toml_str).unwrap();
2219 let local = LocalConfig::default();
2220 let result = merge(config, local);
2221 assert!(result.is_err());
2222 }
2223
2224 #[test]
2225 fn only_skills_false_not_serialized() {
2226 let config = Config {
2227 dependencies: {
2228 let mut m = IndexMap::new();
2229 m.insert(
2230 "base".into(),
2231 DependencyEntry {
2232 url: Some("https://github.com/org/base.git".into()),
2233 path: None,
2234 subpath: None,
2235 version: None,
2236 filter: FilterConfig::default(),
2237 },
2238 );
2239 m
2240 },
2241 settings: Settings::default(),
2242 ..Config::default()
2243 };
2244 let serialized = toml::to_string_pretty(&config).unwrap();
2245 assert!(
2246 !serialized.contains("only_skills"),
2247 "false booleans should not be serialized: {serialized}"
2248 );
2249 assert!(
2250 !serialized.contains("only_agents"),
2251 "false booleans should not be serialized: {serialized}"
2252 );
2253 }
2254
2255 #[test]
2256 fn only_skills_true_roundtrips() {
2257 let toml_str = r#"
2258[dependencies.base]
2259url = "https://github.com/org/base.git"
2260only_skills = true
2261"#;
2262 let config: Config = toml::from_str(toml_str).unwrap();
2263 assert!(config.dependencies["base"].filter.only_skills);
2264 assert!(!config.dependencies["base"].filter.only_agents);
2265
2266 let serialized = toml::to_string_pretty(&config).unwrap();
2267 let reloaded: Config = toml::from_str(&serialized).unwrap();
2268 assert!(reloaded.dependencies["base"].filter.only_skills);
2269 }
2270
2271 #[test]
2272 fn filter_config_has_any_filter() {
2273 assert!(!FilterConfig::default().has_any_filter());
2274 assert!(
2275 FilterConfig {
2276 only_skills: true,
2277 ..FilterConfig::default()
2278 }
2279 .has_any_filter()
2280 );
2281 assert!(
2282 FilterConfig {
2283 agents: Some(vec!["coder".into()]),
2284 ..FilterConfig::default()
2285 }
2286 .has_any_filter()
2287 );
2288 }
2289
2290 #[test]
2291 fn filter_config_to_mode() {
2292 assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
2293 assert!(matches!(
2294 FilterConfig {
2295 only_skills: true,
2296 ..FilterConfig::default()
2297 }
2298 .to_mode(),
2299 FilterMode::OnlySkills
2300 ));
2301 assert!(matches!(
2302 FilterConfig {
2303 only_agents: true,
2304 ..FilterConfig::default()
2305 }
2306 .to_mode(),
2307 FilterMode::OnlyAgents
2308 ));
2309 assert!(matches!(
2310 FilterConfig {
2311 agents: Some(vec!["coder".into()]),
2312 ..FilterConfig::default()
2313 }
2314 .to_mode(),
2315 FilterMode::Include { .. }
2316 ));
2317 assert!(matches!(
2318 FilterConfig {
2319 exclude: Some(vec!["old".into()]),
2320 ..FilterConfig::default()
2321 }
2322 .to_mode(),
2323 FilterMode::Exclude(_)
2324 ));
2325 }
2326
2327 #[test]
2330 fn managed_targets_defaults_to_no_target_sync_targets() {
2331 let settings = Settings::default();
2332 assert!(settings.managed_targets().is_empty());
2333 }
2334
2335 #[test]
2336 fn managed_targets_uses_explicit_targets() {
2337 let settings = Settings {
2338 targets: Some(vec![".claude".to_string()]),
2339 ..Settings::default()
2340 };
2341 assert_eq!(settings.managed_targets(), vec![".claude"]);
2342 }
2343
2344 #[test]
2345 fn managed_targets_uses_managed_root_as_primary() {
2346 let settings = Settings {
2347 managed_root: Some(".claude".to_string()),
2348 ..Settings::default()
2349 };
2350 assert_eq!(settings.managed_targets(), vec![".claude"]);
2351 }
2352
2353 #[test]
2354 fn managed_targets_explicit_overrides_links_and_managed_root() {
2355 let settings = Settings {
2356 managed_root: Some(".cursor".to_string()),
2357 targets: Some(vec![".codex".to_string()]),
2358 ..Settings::default()
2359 };
2360 assert_eq!(settings.managed_targets(), vec![".codex"]);
2362 }
2363
2364 #[test]
2365 fn managed_targets_normalizes_bare_harness_and_generic_links() {
2366 let settings = Settings {
2367 targets: Some(vec![
2368 "codex".to_string(),
2369 "agents".to_string(),
2370 "foo".to_string(),
2371 ]),
2372 ..Settings::default()
2373 };
2374 assert_eq!(
2375 settings.managed_targets(),
2376 vec![
2377 ".codex".to_string(),
2378 ".agents".to_string(),
2379 ".foo".to_string()
2380 ]
2381 );
2382 }
2383
2384 #[test]
2385 fn linked_harnesses_extracts_legacy_path_form_harness_links() {
2386 let settings = Settings {
2387 targets: Some(vec![
2388 ".codex".to_string(),
2389 ".claude".to_string(),
2390 ".agents".to_string(),
2391 ]),
2392 ..Settings::default()
2393 };
2394 assert_eq!(
2395 settings.linked_harnesses(),
2396 vec!["codex".to_string(), "claude".to_string()]
2397 );
2398 }
2399
2400 #[test]
2401 fn merge_warns_when_managed_root_is_agents() {
2402 let config = Config {
2403 settings: Settings {
2404 managed_root: Some(".agents".into()),
2405 ..Settings::default()
2406 },
2407 ..Config::default()
2408 };
2409
2410 let (_, diagnostics) =
2411 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
2412
2413 assert!(diagnostics.iter().any(|diag| {
2414 diag.code == "deprecated-agents-target"
2415 && diag.context.as_deref() == Some("settings.managed_root")
2416 }));
2417 }
2418
2419 #[test]
2420 fn merge_warns_when_targets_include_agents() {
2421 let config = Config {
2422 settings: Settings {
2423 targets: Some(vec![".agents".into(), ".claude".into()]),
2424 ..Settings::default()
2425 },
2426 ..Config::default()
2427 };
2428
2429 let (_, diagnostics) =
2430 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
2431
2432 assert!(diagnostics.iter().any(|diag| {
2433 diag.code == "deprecated-agents-target"
2434 && diag.context.as_deref() == Some("settings.targets")
2435 }));
2436 }
2437
2438 #[test]
2439 fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
2440 let config: Config = toml::from_str(
2441 r#"
2442[dependencies.base]
2443url = "https://github.com/org/base.git"
2444"#,
2445 )
2446 .unwrap();
2447 assert_eq!(config.settings.models_cache_ttl_hours, 24);
2448 }
2449
2450 #[test]
2451 fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
2452 let config: Config = toml::from_str(
2453 r#"
2454[settings]
2455managed_root = ".agents"
2456"#,
2457 )
2458 .unwrap();
2459 assert_eq!(config.settings.models_cache_ttl_hours, 24);
2460 }
2461
2462 #[test]
2463 fn settings_models_cache_ttl_parses_zero() {
2464 let config: Config = toml::from_str(
2465 r#"
2466[settings]
2467models_cache_ttl_hours = 0
2468"#,
2469 )
2470 .unwrap();
2471 assert_eq!(config.settings.models_cache_ttl_hours, 0);
2472 }
2473
2474 #[test]
2475 fn settings_models_cache_ttl_parses_custom_value() {
2476 let config: Config = toml::from_str(
2477 r#"
2478[settings]
2479models_cache_ttl_hours = 48
2480"#,
2481 )
2482 .unwrap();
2483 assert_eq!(config.settings.models_cache_ttl_hours, 48);
2484 }
2485
2486 #[test]
2487 fn settings_models_cache_ttl_roundtrip_preserves_value() {
2488 let original = Config {
2489 settings: Settings {
2490 models_cache_ttl_hours: 48,
2491 ..Settings::default()
2492 },
2493 ..Config::default()
2494 };
2495 let serialized = toml::to_string_pretty(&original).unwrap();
2496 let roundtripped: Config = toml::from_str(&serialized).unwrap();
2497 assert_eq!(
2498 roundtripped.settings.models_cache_ttl_hours,
2499 original.settings.models_cache_ttl_hours
2500 );
2501 }
2502
2503 #[test]
2504 fn settings_agent_emission_parses_auto() {
2505 let config: Config = toml::from_str(
2506 r#"
2507[settings]
2508agent_emission = "auto"
2509"#,
2510 )
2511 .unwrap();
2512 assert_eq!(config.settings.agent_emission, Some(AgentEmission::Auto));
2513 }
2514
2515 #[test]
2516 fn settings_agent_emission_parses_always_and_never() {
2517 let always: Config = toml::from_str(
2518 r#"
2519[settings]
2520agent_emission = "always"
2521"#,
2522 )
2523 .unwrap();
2524 assert_eq!(always.settings.agent_emission, Some(AgentEmission::Always));
2525
2526 let never: Config = toml::from_str(
2527 r#"
2528[settings]
2529agent_emission = "never"
2530"#,
2531 )
2532 .unwrap();
2533 assert_eq!(never.settings.agent_emission, Some(AgentEmission::Never));
2534 }
2535
2536 #[test]
2537 fn settings_agent_emission_defaults_to_auto_when_omitted() {
2538 let config: Config = toml::from_str(
2539 r#"
2540[settings]
2541models_cache_ttl_hours = 48
2542"#,
2543 )
2544 .unwrap();
2545 assert!(config.settings.agent_emission.is_none());
2546 }
2547
2548 #[test]
2549 fn settings_default_harness_parses_and_roundtrips() {
2550 let config: Config = toml::from_str(
2551 r#"
2552[settings]
2553default_harness = "codex"
2554"#,
2555 )
2556 .unwrap();
2557 assert_eq!(config.settings.default_harness.as_deref(), Some("codex"));
2558
2559 let serialized = toml::to_string_pretty(&config).unwrap();
2560 let roundtripped: Config = toml::from_str(&serialized).unwrap();
2561 assert_eq!(
2562 roundtripped.settings.default_harness,
2563 config.settings.default_harness
2564 );
2565 }
2566
2567 #[test]
2568 fn settings_default_model_parses_and_roundtrips() {
2569 let config: Config = toml::from_str(
2570 r#"
2571[settings]
2572default_model = "gpt-5.4-mini"
2573"#,
2574 )
2575 .unwrap();
2576 assert_eq!(
2577 config.settings.default_model.as_deref(),
2578 Some("gpt-5.4-mini")
2579 );
2580
2581 let serialized = toml::to_string_pretty(&config).unwrap();
2582 let roundtripped: Config = toml::from_str(&serialized).unwrap();
2583 assert_eq!(
2584 roundtripped.settings.default_model,
2585 config.settings.default_model
2586 );
2587 }
2588
2589 #[test]
2590 fn settings_harness_order_parses_and_roundtrips() {
2591 let config: Config = toml::from_str(
2592 r#"
2593[settings]
2594harness_order = ["pi", "opencode", "codex", "claude"]
2595"#,
2596 )
2597 .unwrap();
2598 assert_eq!(
2599 config.settings.harness_order,
2600 Some(vec![
2601 "pi".to_string(),
2602 "opencode".to_string(),
2603 "codex".to_string(),
2604 "claude".to_string()
2605 ])
2606 );
2607
2608 let serialized = toml::to_string_pretty(&config).unwrap();
2609 let roundtripped: Config = toml::from_str(&serialized).unwrap();
2610 assert_eq!(
2611 roundtripped.settings.harness_order,
2612 config.settings.harness_order
2613 );
2614 }
2615
2616 #[test]
2617 fn settings_agent_emission_roundtrip_preserves_value() {
2618 let original = Config {
2619 settings: Settings {
2620 agent_emission: Some(AgentEmission::Always),
2621 ..Settings::default()
2622 },
2623 ..Config::default()
2624 };
2625 let serialized = toml::to_string_pretty(&original).unwrap();
2626 let roundtripped: Config = toml::from_str(&serialized).unwrap();
2627 assert_eq!(
2628 roundtripped.settings.agent_emission,
2629 original.settings.agent_emission
2630 );
2631 }
2632
2633 #[test]
2634 fn model_visibility_validate_allows_include_and_exclude() {
2635 let visibility = ModelVisibility {
2636 include: Some(vec!["opus*".into()]),
2637 exclude: Some(vec!["test*".into()]),
2638 };
2639 visibility.validate().unwrap();
2640 }
2641
2642 #[test]
2643 fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
2644 ModelVisibility {
2645 include: Some(vec!["opus*".into()]),
2646 exclude: None,
2647 }
2648 .validate()
2649 .unwrap();
2650 ModelVisibility {
2651 include: None,
2652 exclude: Some(vec!["test*".into()]),
2653 }
2654 .validate()
2655 .unwrap();
2656 ModelVisibility::default().validate().unwrap();
2657 }
2658
2659 #[test]
2660 fn model_visibility_is_empty_reports_state() {
2661 assert!(ModelVisibility::default().is_empty());
2662 assert!(
2663 !ModelVisibility {
2664 include: Some(vec!["opus*".into()]),
2665 exclude: None,
2666 }
2667 .is_empty()
2668 );
2669 assert!(
2670 !ModelVisibility {
2671 include: None,
2672 exclude: Some(vec!["test*".into()]),
2673 }
2674 .is_empty()
2675 );
2676 }
2677
2678 #[test]
2679 fn load_accepts_model_visibility_with_include_and_exclude() {
2680 let dir = TempDir::new().unwrap();
2681 std::fs::write(
2682 dir.path().join("mars.toml"),
2683 r#"
2684[settings.model_visibility]
2685include = ["opus*"]
2686exclude = ["test*"]
2687"#,
2688 )
2689 .unwrap();
2690
2691 let config = load(dir.path()).unwrap();
2692 assert_eq!(
2693 config.settings.model_visibility.include,
2694 Some(vec!["opus*".into()])
2695 );
2696 assert_eq!(
2697 config.settings.model_visibility.exclude,
2698 Some(vec!["test*".into()])
2699 );
2700 }
2701
2702 #[test]
2703 fn load_accepts_model_visibility_include_only() {
2704 let dir = TempDir::new().unwrap();
2705 std::fs::write(
2706 dir.path().join("mars.toml"),
2707 r#"
2708[settings.model_visibility]
2709include = ["opus*", "gpt-*"]
2710"#,
2711 )
2712 .unwrap();
2713
2714 let config = load(dir.path()).unwrap();
2715 assert_eq!(
2716 config.settings.model_visibility.include,
2717 Some(vec!["opus*".into(), "gpt-*".into()])
2718 );
2719 assert!(config.settings.model_visibility.exclude.is_none());
2720 }
2721
2722 #[test]
2723 fn load_accepts_model_visibility_exclude_only() {
2724 let dir = TempDir::new().unwrap();
2725 std::fs::write(
2726 dir.path().join("mars.toml"),
2727 r#"
2728[settings.model_visibility]
2729exclude = ["test-*", "deprecated-*"]
2730"#,
2731 )
2732 .unwrap();
2733
2734 let config = load(dir.path()).unwrap();
2735 assert_eq!(
2736 config.settings.model_visibility.exclude,
2737 Some(vec!["test-*".into(), "deprecated-*".into()])
2738 );
2739 assert!(config.settings.model_visibility.include.is_none());
2740 }
2741
2742 #[test]
2745 fn parse_local_dependencies() {
2746 let toml_str = r#"
2747[dependencies.base]
2748url = "https://github.com/org/base.git"
2749
2750[local-dependencies.prompter]
2751url = "https://github.com/org/prompter.git"
2752skills = ["prompt-helper"]
2753"#;
2754 let config: Config = toml::from_str(toml_str).unwrap();
2755 assert_eq!(config.dependencies.len(), 1);
2756 assert_eq!(config.local_dependencies.len(), 1);
2757 assert!(config.local_dependencies.contains_key("prompter"));
2758 assert_eq!(
2759 config.local_dependencies["prompter"].url.as_deref(),
2760 Some("https://github.com/org/prompter.git")
2761 );
2762 }
2763
2764 #[test]
2765 fn local_dependencies_merged_into_effective_config() {
2766 let toml_str = r#"
2767[dependencies.base]
2768url = "https://github.com/org/base.git"
2769
2770[local-dependencies.prompter]
2771url = "https://github.com/org/prompter.git"
2772"#;
2773 let config: Config = toml::from_str(toml_str).unwrap();
2774 let local = LocalConfig::default();
2775 let effective = merge(config, local).unwrap();
2776
2777 assert_eq!(effective.dependencies.len(), 2);
2779 assert!(effective.dependencies.contains_key("base"));
2780 assert!(effective.dependencies.contains_key("prompter"));
2781 }
2782
2783 #[test]
2784 fn local_dependencies_not_exported_to_manifest() {
2785 let dir = TempDir::new().unwrap();
2786 std::fs::write(
2787 dir.path().join("mars.toml"),
2788 r#"
2789[package]
2790name = "my-package"
2791version = "1.0.0"
2792
2793[dependencies.base]
2794url = "https://github.com/org/base.git"
2795
2796[local-dependencies.prompter]
2797url = "https://github.com/org/prompter.git"
2798"#,
2799 )
2800 .unwrap();
2801
2802 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
2803 assert!(diagnostics.is_empty());
2804 let manifest = manifest.unwrap();
2805
2806 assert_eq!(manifest.dependencies.len(), 1);
2808 assert!(manifest.dependencies.contains_key("base"));
2809 assert!(!manifest.dependencies.contains_key("prompter"));
2810 }
2811
2812 #[test]
2813 fn error_on_duplicate_name_across_sections() {
2814 let toml_str = r#"
2815[dependencies.base]
2816url = "https://github.com/org/base.git"
2817
2818[local-dependencies.base]
2819url = "https://github.com/org/base-local.git"
2820"#;
2821 let config: Config = toml::from_str(toml_str).unwrap();
2822 let local = LocalConfig::default();
2823 let result = merge(config, local);
2824 assert!(result.is_err());
2825 let err = result.unwrap_err().to_string();
2826 assert!(
2827 err.contains("base") && err.contains("both"),
2828 "should reject duplicate name: {err}"
2829 );
2830 }
2831
2832 #[test]
2833 fn local_dependencies_roundtrip() {
2834 let dir = TempDir::new().unwrap();
2835 let original = r#"
2836[dependencies.base]
2837url = "https://github.com/org/base.git"
2838
2839[local-dependencies.prompter]
2840url = "https://github.com/org/prompter.git"
2841skills = ["prompt-helper"]
2842"#;
2843 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2844
2845 let config = load(dir.path()).unwrap();
2846 save(dir.path(), &config).unwrap();
2847 let reloaded = load(dir.path()).unwrap();
2848
2849 assert_eq!(reloaded.dependencies.len(), 1);
2850 assert_eq!(reloaded.local_dependencies.len(), 1);
2851 assert!(reloaded.local_dependencies.contains_key("prompter"));
2852 assert_eq!(
2853 reloaded.local_dependencies["prompter"]
2854 .filter
2855 .skills
2856 .as_deref(),
2857 Some(&["prompt-helper".into()][..])
2858 );
2859 }
2860
2861 #[test]
2862 fn path_with_backslashes_serializes_as_forward_slashes() {
2863 let mut deps = IndexMap::new();
2864 deps.insert(
2865 SourceName::from("test-src"),
2866 InstallDep {
2867 url: None,
2868 path: Some(PathBuf::from("C:\\Users\\dev\\src")),
2869 subpath: None,
2870 version: None,
2871 filter: FilterConfig::default(),
2872 },
2873 );
2874 let config = Config {
2875 dependencies: deps,
2876 ..Config::default()
2877 };
2878 let toml_str = toml::to_string_pretty(&config).unwrap();
2879 assert!(
2880 !toml_str.contains('\\'),
2881 "TOML output must not contain backslashes: {toml_str}"
2882 );
2883 assert!(
2884 toml_str.contains("C:/Users/dev/src"),
2885 "expected forward-slash path in TOML: {toml_str}"
2886 );
2887 let reparsed: Config = toml::from_str(&toml_str).unwrap();
2888 assert_eq!(
2889 reparsed.dependencies["test-src"].path.as_ref().unwrap(),
2890 &PathBuf::from("C:/Users/dev/src"),
2891 );
2892 }
2893
2894 #[test]
2895 fn override_path_serializes_forward_slashes() {
2896 let mut overrides = IndexMap::new();
2897 overrides.insert(
2898 SourceName::from("my-dep"),
2899 OverrideEntry {
2900 path: PathBuf::from("C:\\Users\\dev\\local-pkg"),
2901 },
2902 );
2903 let local = LocalConfig {
2904 overrides,
2905 ..LocalConfig::default()
2906 };
2907 let toml_str = toml::to_string_pretty(&local).unwrap();
2908 assert!(
2909 !toml_str.contains('\\'),
2910 "local config TOML must not contain backslashes: {toml_str}"
2911 );
2912 assert!(
2913 toml_str.contains("C:/Users/dev/local-pkg"),
2914 "expected forward-slash override path: {toml_str}"
2915 );
2916 }
2917}