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