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