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