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