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