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