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 declared_source_id: SourceId,
784 pub source_id: SourceId,
786 pub spec: SourceSpec,
787 pub subpath: Option<SourceSubpath>,
788 pub filter: FilterMode,
789 pub rename: RenameMap,
790 pub dialect: Option<Dialect>,
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 subpath = entry.subpath.clone();
1042 let declared_source_id = source_id_for_spec(root, &base_spec, subpath.clone());
1043
1044 let spec = if let Some(ov) = local.overrides.get(name) {
1046 SourceSpec::Path(ov.path.clone())
1047 } else {
1048 base_spec
1049 };
1050 let dialect = entry.dialect;
1051 let effective_source_id = source_id_for_spec(root, &spec, subpath.clone());
1052
1053 dependencies.insert(
1054 name.clone(),
1055 EffectiveDependency {
1056 declared_source_id,
1057 source_id: effective_source_id,
1058 spec,
1059 subpath,
1060 filter,
1061 rename,
1062 dialect,
1063 },
1064 );
1065 }
1066
1067 let skills = overlay_skills_replace_by_key(&config.skills, &local.skills);
1068
1069 Ok((
1070 EffectiveConfig {
1071 dependencies,
1072 settings: merged_settings,
1073 skills,
1074 },
1075 diagnostics,
1076 ))
1077}
1078
1079fn deprecated_agents_target_diagnostics(settings: &Settings) -> Vec<Diagnostic> {
1080 let mut diagnostics = Vec::new();
1081
1082 if settings.managed_root.as_deref() == Some(".agents") {
1083 diagnostics.push(deprecated_agents_target_diagnostic("settings.managed_root"));
1084 }
1085
1086 if settings
1087 .targets
1088 .as_ref()
1089 .is_some_and(|targets| targets.iter().any(|target| target == ".agents"))
1090 {
1091 diagnostics.push(deprecated_agents_target_diagnostic("settings.targets"));
1092 }
1093
1094 diagnostics
1095}
1096
1097fn deprecated_agents_target_diagnostic(context: &str) -> Diagnostic {
1098 Diagnostic {
1099 level: DiagnosticLevel::Warning,
1100 code: "deprecated-agents-target",
1101 message: format!(
1102 "`.agents` is a deprecated link target. Run `{}` to remove it. Skills are now emitted to native harness dirs automatically.",
1103 managed_cmd("mars unlink .agents"),
1104 ),
1105 context: Some(context.to_string()),
1106 category: Some(DiagnosticCategory::Compatibility),
1107 }
1108}
1109
1110pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
1118 let has_include = filter.agents.is_some() || filter.skills.is_some();
1119 let has_exclude = filter.exclude.is_some();
1120 let has_category = filter.only_skills || filter.only_agents;
1121
1122 if filter.only_skills && filter.only_agents {
1123 return Err(ConfigError::Invalid {
1124 message: format!(
1125 "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
1126 ),
1127 }
1128 .into());
1129 }
1130 if has_category && has_include {
1131 return Err(ConfigError::Invalid {
1132 message: format!(
1133 "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
1134 ),
1135 }
1136 .into());
1137 }
1138 if has_category && has_exclude {
1139 return Err(ConfigError::Invalid {
1140 message: format!(
1141 "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
1142 ),
1143 }
1144 .into());
1145 }
1146 if has_include && has_exclude {
1147 return Err(ConfigError::ConflictingFilters {
1148 name: dep_name.to_string(),
1149 }
1150 .into());
1151 }
1152 Ok(())
1153}
1154
1155impl FilterConfig {
1156 pub fn to_mode(&self) -> FilterMode {
1158 if self.only_skills {
1159 FilterMode::OnlySkills
1160 } else if self.only_agents {
1161 FilterMode::OnlyAgents
1162 } else if self.agents.is_some() || self.skills.is_some() {
1163 FilterMode::Include {
1164 agents: self.agents.clone().unwrap_or_default(),
1165 skills: self.skills.clone().unwrap_or_default(),
1166 }
1167 } else if self.exclude.is_some() {
1168 FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
1169 } else {
1170 FilterMode::All
1171 }
1172 }
1173
1174 pub fn has_any_filter(&self) -> bool {
1176 self.agents.is_some()
1177 || self.skills.is_some()
1178 || self.exclude.is_some()
1179 || self.only_skills
1180 || self.only_agents
1181 }
1182}
1183
1184fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
1185 match spec {
1186 SourceSpec::Git(git) => {
1187 let canonical_url = SourceUrl::from(crate::source::canonical::canonicalize_git_url(
1188 git.url.as_ref(),
1189 ));
1190 SourceId::git_with_subpath(canonical_url, subpath.clone())
1191 }
1192 SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
1193 Ok(id) => id,
1194 Err(_) => {
1195 let canonical = if path.is_absolute() {
1196 path.clone()
1197 } else {
1198 root.join(path)
1199 };
1200 SourceId::Path { canonical, subpath }
1201 }
1202 },
1203 }
1204}
1205
1206fn migrate_legacy_source_urls(config: &mut Config) {
1207 for dep in config
1208 .dependencies
1209 .values_mut()
1210 .chain(config.local_dependencies.values_mut())
1211 {
1212 if let Some(url) = dep.url.as_mut() {
1213 let raw = url.as_str();
1214 if should_upgrade_legacy_git_url(raw) {
1215 *url = SourceUrl::from(format!("https://{raw}"));
1216 }
1217 }
1218 }
1219}
1220
1221fn should_upgrade_legacy_git_url(url: &str) -> bool {
1222 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
1223}
1224
1225pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
1227 let path = root.join(CONFIG_FILE);
1228 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
1229 message: format!("failed to serialize config: {e}"),
1230 })?;
1231 let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
1232 message: format!("refusing to save config: serialized output failed to parse: {e}"),
1233 })?;
1234 validate_save_roundtrip(config, &reparsed)?;
1235 crate::fs::atomic_write(&path, content.as_bytes())
1236}
1237
1238fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
1239 if reparsed.dependencies.len() != original.dependencies.len() {
1240 return Err(ConfigError::Invalid {
1241 message: format!(
1242 "refusing to save config: dependency count changed during roundtrip ({} -> {})",
1243 original.dependencies.len(),
1244 reparsed.dependencies.len()
1245 ),
1246 }
1247 .into());
1248 }
1249
1250 if reparsed.local_dependencies.len() != original.local_dependencies.len() {
1251 return Err(ConfigError::Invalid {
1252 message: format!(
1253 "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
1254 original.local_dependencies.len(),
1255 reparsed.local_dependencies.len()
1256 ),
1257 }
1258 .into());
1259 }
1260
1261 if reparsed.settings.managed_root != original.settings.managed_root {
1262 return Err(ConfigError::Invalid {
1263 message: format!(
1264 "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
1265 original.settings.managed_root, reparsed.settings.managed_root
1266 ),
1267 }
1268 .into());
1269 }
1270 if reparsed.settings.model_visibility != original.settings.model_visibility {
1271 return Err(ConfigError::Invalid {
1272 message: format!(
1273 "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
1274 original.settings.model_visibility, reparsed.settings.model_visibility
1275 ),
1276 }
1277 .into());
1278 }
1279 if reparsed.settings.default_harness != original.settings.default_harness {
1280 return Err(ConfigError::Invalid {
1281 message: format!(
1282 "refusing to save config: settings.default_harness changed during roundtrip ({:?} -> {:?})",
1283 original.settings.default_harness, reparsed.settings.default_harness
1284 ),
1285 }
1286 .into());
1287 }
1288 if reparsed.settings.default_model != original.settings.default_model {
1289 return Err(ConfigError::Invalid {
1290 message: format!(
1291 "refusing to save config: settings.default_model changed during roundtrip ({:?} -> {:?})",
1292 original.settings.default_model, reparsed.settings.default_model
1293 ),
1294 }
1295 .into());
1296 }
1297 if reparsed.settings.harness_order != original.settings.harness_order {
1298 return Err(ConfigError::Invalid {
1299 message: format!(
1300 "refusing to save config: settings.harness_order changed during roundtrip ({:?} -> {:?})",
1301 original.settings.harness_order, reparsed.settings.harness_order
1302 ),
1303 }
1304 .into());
1305 }
1306 if reparsed.settings.provider_order != original.settings.provider_order {
1307 return Err(ConfigError::Invalid {
1308 message: format!(
1309 "refusing to save config: settings.provider_order changed during roundtrip ({:?} -> {:?})",
1310 original.settings.provider_order, reparsed.settings.provider_order
1311 ),
1312 }
1313 .into());
1314 }
1315 if reparsed.settings.agent_emission != original.settings.agent_emission {
1316 return Err(ConfigError::Invalid {
1317 message: format!(
1318 "refusing to save config: settings.agent_emission changed during roundtrip ({:?} -> {:?})",
1319 original.settings.agent_emission, reparsed.settings.agent_emission
1320 ),
1321 }
1322 .into());
1323 }
1324 if reparsed.settings.meridian.agent_copy != original.settings.meridian.agent_copy {
1325 return Err(ConfigError::Invalid {
1326 message: format!(
1327 "refusing to save config: settings.meridian.agent_copy changed during roundtrip ({:?} -> {:?})",
1328 original.settings.meridian.agent_copy, reparsed.settings.meridian.agent_copy
1329 ),
1330 }
1331 .into());
1332 }
1333 if reparsed.settings.meridian.fanout != original.settings.meridian.fanout {
1334 return Err(ConfigError::Invalid {
1335 message: format!(
1336 "refusing to save config: settings.meridian.fanout changed during roundtrip ({:?} -> {:?})",
1337 original.settings.meridian.fanout, reparsed.settings.meridian.fanout
1338 ),
1339 }
1340 .into());
1341 }
1342 if reparsed.settings.model_policies != original.settings.model_policies {
1343 return Err(ConfigError::Invalid {
1344 message: "refusing to save config: settings.model_policies changed during roundtrip"
1345 .to_string(),
1346 }
1347 .into());
1348 }
1349 if reparsed.agents != original.agents {
1350 return Err(ConfigError::Invalid {
1351 message: "refusing to save config: agents changed during roundtrip".to_string(),
1352 }
1353 .into());
1354 }
1355
1356 for (name, dep) in &original.dependencies {
1357 let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
1358 return Err(ConfigError::Invalid {
1359 message: format!(
1360 "refusing to save config: dependency `{name}` missing after roundtrip"
1361 ),
1362 }
1363 .into());
1364 };
1365
1366 if reparsed_dep != dep {
1367 return Err(ConfigError::Invalid {
1368 message: format!(
1369 "refusing to save config: dependency `{name}` changed during roundtrip"
1370 ),
1371 }
1372 .into());
1373 }
1374 }
1375
1376 for (name, dep) in &original.local_dependencies {
1377 let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
1378 return Err(ConfigError::Invalid {
1379 message: format!(
1380 "refusing to save config: local-dependency `{name}` missing after roundtrip"
1381 ),
1382 }
1383 .into());
1384 };
1385
1386 if reparsed_dep != dep {
1387 return Err(ConfigError::Invalid {
1388 message: format!(
1389 "refusing to save config: local-dependency `{name}` changed during roundtrip"
1390 ),
1391 }
1392 .into());
1393 }
1394 }
1395
1396 Ok(())
1397}
1398
1399pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
1401 let path = root.join(LOCAL_CONFIG_FILE);
1402 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
1403 message: format!("failed to serialize local config: {e}"),
1404 })?;
1405 crate::fs::atomic_write(&path, content.as_bytes())
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411 use crate::models::{ModelAlias, ModelSpec};
1412 use tempfile::TempDir;
1413
1414 #[test]
1415 fn parse_git_dependency() {
1416 let toml_str = r#"
1417[dependencies.base]
1418url = "https://github.com/org/base.git"
1419version = "v1.0"
1420"#;
1421 let config: Config = toml::from_str(toml_str).unwrap();
1422 assert_eq!(config.dependencies.len(), 1);
1423 let entry = &config.dependencies["base"];
1424 assert_eq!(
1425 entry.url.as_deref(),
1426 Some("https://github.com/org/base.git")
1427 );
1428 assert!(entry.path.is_none());
1429 assert_eq!(entry.version.as_deref(), Some("v1.0"));
1430 }
1431
1432 #[test]
1433 fn parse_path_dependency() {
1434 let toml_str = r#"
1435[dependencies.local]
1436path = "../my-agents"
1437"#;
1438 let config: Config = toml::from_str(toml_str).unwrap();
1439 let entry = &config.dependencies["local"];
1440 assert!(entry.url.is_none());
1441 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
1442 }
1443
1444 #[test]
1445 fn parse_mixed_dependencies() {
1446 let toml_str = r#"
1447[dependencies.remote]
1448url = "https://github.com/org/remote.git"
1449version = "v2.0"
1450agents = ["coder", "reviewer"]
1451
1452[dependencies.local]
1453path = "/home/dev/agents"
1454exclude = ["experimental"]
1455"#;
1456 let config: Config = toml::from_str(toml_str).unwrap();
1457 assert_eq!(config.dependencies.len(), 2);
1458 assert!(config.dependencies.contains_key("remote"));
1459 assert!(config.dependencies.contains_key("local"));
1460 }
1461
1462 #[test]
1463 fn parse_package_and_dependencies_coexist() {
1464 let toml_str = r#"
1465[package]
1466name = "my-agents"
1467version = "0.1.0"
1468
1469[dependencies.base]
1470url = "https://github.com/org/base.git"
1471version = ">=1.0.0"
1472
1473[dependencies.local]
1474path = "../local-agents"
1475"#;
1476 let config: Config = toml::from_str(toml_str).unwrap();
1477 assert!(config.package.is_some());
1478 assert!(config.dependencies.contains_key("base"));
1479 assert!(config.dependencies.contains_key("local"));
1480 }
1481
1482 #[test]
1483 fn parse_include_filter() {
1484 let toml_str = r#"
1485[dependencies.base]
1486url = "https://github.com/org/base.git"
1487agents = ["coder"]
1488skills = ["review"]
1489"#;
1490 let config: Config = toml::from_str(toml_str).unwrap();
1491 let local = LocalConfig::default();
1492 let effective = merge(config, local).unwrap();
1493 let source = &effective.dependencies["base"];
1494 match &source.filter {
1495 FilterMode::Include { agents, skills } => {
1496 assert_eq!(agents, &["coder"]);
1497 assert_eq!(skills, &["review"]);
1498 }
1499 other => panic!("expected Include, got {other:?}"),
1500 }
1501 }
1502
1503 #[test]
1504 fn parse_exclude_filter() {
1505 let toml_str = r#"
1506[dependencies.base]
1507url = "https://github.com/org/base.git"
1508exclude = ["experimental", "deprecated"]
1509"#;
1510 let config: Config = toml::from_str(toml_str).unwrap();
1511 let local = LocalConfig::default();
1512 let effective = merge(config, local).unwrap();
1513 let source = &effective.dependencies["base"];
1514 match &source.filter {
1515 FilterMode::Exclude(items) => {
1516 assert_eq!(items, &["experimental", "deprecated"]);
1517 }
1518 other => panic!("expected Exclude, got {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1523 fn error_on_both_include_and_exclude() {
1524 let toml_str = r#"
1525[dependencies.bad]
1526url = "https://github.com/org/bad.git"
1527agents = ["coder"]
1528exclude = ["reviewer"]
1529"#;
1530 let config: Config = toml::from_str(toml_str).unwrap();
1531 let local = LocalConfig::default();
1532 let result = merge(config, local);
1533 assert!(result.is_err());
1534 let err = result.unwrap_err().to_string();
1535 assert!(
1536 err.contains("bad"),
1537 "error should mention dependency name: {err}"
1538 );
1539 }
1540
1541 #[test]
1542 fn error_on_neither_url_nor_path() {
1543 let toml_str = r#"
1544[dependencies.empty]
1545version = "v1.0"
1546"#;
1547 let config: Config = toml::from_str(toml_str).unwrap();
1548 let local = LocalConfig::default();
1549 let result = merge(config, local);
1550 assert!(result.is_err());
1551 let err = result.unwrap_err().to_string();
1552 assert!(
1553 err.contains("neither"),
1554 "error should mention 'neither': {err}"
1555 );
1556 }
1557
1558 #[test]
1559 fn error_on_both_url_and_path() {
1560 let toml_str = r#"
1561[dependencies.both]
1562url = "https://github.com/org/repo.git"
1563path = "/local/path"
1564"#;
1565 let config: Config = toml::from_str(toml_str).unwrap();
1566 let local = LocalConfig::default();
1567 let result = merge(config, local);
1568 assert!(result.is_err());
1569 let err = result.unwrap_err().to_string();
1570 assert!(err.contains("both"), "error should mention 'both': {err}");
1571 }
1572
1573 #[test]
1574 fn roundtrip_full_config_shape_survives_save() {
1575 let dir = TempDir::new().unwrap();
1576 let original = r#"
1577[package]
1578name = "sample"
1579version = "0.1.0"
1580description = "sample package"
1581
1582[dependencies.base]
1583url = "https://github.com/org/base.git"
1584version = "v1.0"
1585agents = ["coder", "reviewer"]
1586
1587[dependencies.local]
1588path = "../local-agents"
1589exclude = ["experimental"]
1590
1591[settings]
1592managed_root = ".custom-agents"
1593targets = [".claude", ".cursor"]
1594harness_order = ["pi", "opencode", "codex"]
1595"#;
1596 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1597
1598 let config = load(dir.path()).unwrap();
1599 save(dir.path(), &config).unwrap();
1600 let reloaded = load(dir.path()).unwrap();
1601
1602 assert_eq!(
1603 reloaded.package.as_ref().map(|p| p.name.as_str()),
1604 Some("sample")
1605 );
1606 assert_eq!(reloaded.dependencies.len(), 2);
1607 assert_eq!(
1608 reloaded.dependencies["base"].url.as_deref(),
1609 Some("https://github.com/org/base.git")
1610 );
1611 assert_eq!(
1612 reloaded.dependencies["local"].path.as_deref(),
1613 Some(Path::new("../local-agents"))
1614 );
1615 assert_eq!(
1616 reloaded.settings.managed_root.as_deref(),
1617 Some(".custom-agents")
1618 );
1619 assert_eq!(
1620 reloaded.settings.targets,
1621 Some(vec![".claude".to_string(), ".cursor".to_string()])
1622 );
1623 assert_eq!(
1624 reloaded.settings.harness_order,
1625 Some(vec![
1626 "pi".to_string(),
1627 "opencode".to_string(),
1628 "codex".to_string()
1629 ])
1630 );
1631 }
1632
1633 #[test]
1634 fn load_from_disk() {
1635 let dir = TempDir::new().unwrap();
1636 let toml_str = r#"
1637[dependencies.base]
1638url = "https://github.com/org/base.git"
1639version = "v1.0"
1640"#;
1641 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1642 let config = load(dir.path()).unwrap();
1643 assert_eq!(config.dependencies.len(), 1);
1644 }
1645
1646 #[test]
1647 fn load_migrates_legacy_bare_domain_url() {
1648 let dir = TempDir::new().unwrap();
1649 let toml_str = r#"
1650[dependencies.base]
1651url = "github.com/org/base"
1652"#;
1653 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1654
1655 let config = load(dir.path()).unwrap();
1656 assert_eq!(
1657 config.dependencies["base"].url.as_deref(),
1658 Some("https://github.com/org/base")
1659 );
1660 }
1661
1662 #[test]
1663 fn load_does_not_migrate_ssh_url() {
1664 let dir = TempDir::new().unwrap();
1665 let toml_str = r#"
1666[dependencies.base]
1667url = "git@github.com:org/base.git"
1668"#;
1669 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1670
1671 let config = load(dir.path()).unwrap();
1672 assert_eq!(
1673 config.dependencies["base"].url.as_deref(),
1674 Some("git@github.com:org/base.git")
1675 );
1676 }
1677
1678 #[test]
1679 fn load_missing_file_returns_not_found() {
1680 let dir = TempDir::new().unwrap();
1681 let result = load(dir.path());
1682 assert!(result.is_err());
1683 let err = result.unwrap_err().to_string();
1684 assert!(err.contains("not found"), "should be NotFound: {err}");
1685 }
1686
1687 #[test]
1688 fn load_manifest_returns_none_without_package() {
1689 let dir = TempDir::new().unwrap();
1690 std::fs::write(
1691 dir.path().join("mars.toml"),
1692 r#"
1693[dependencies.base]
1694url = "https://github.com/org/base.git"
1695"#,
1696 )
1697 .unwrap();
1698
1699 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1700 assert!(diagnostics.is_empty());
1701 assert!(manifest.is_none());
1702 }
1703
1704 #[test]
1705 fn load_manifest_returns_package_and_dependencies() {
1706 let dir = TempDir::new().unwrap();
1707 std::fs::write(
1708 dir.path().join("mars.toml"),
1709 r#"
1710[package]
1711name = "pkg"
1712version = "1.2.3"
1713
1714[dependencies.base]
1715url = "https://github.com/org/base.git"
1716version = ">=1.0.0"
1717skills = ["frontend-design"]
1718"#,
1719 )
1720 .unwrap();
1721
1722 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1723 assert!(diagnostics.is_empty());
1724 let manifest = manifest.unwrap();
1725 assert_eq!(manifest.package.name, "pkg");
1726 assert_eq!(manifest.package.version, "1.2.3");
1727 assert!(manifest.dependencies.contains_key("base"));
1728 assert_eq!(
1729 manifest.dependencies["base"].filter.skills.as_deref(),
1730 Some(&[ItemName::from("frontend-design")][..])
1731 );
1732 }
1733
1734 #[test]
1735 fn load_manifest_io_error_includes_operation_and_path() {
1736 let dir = TempDir::new().unwrap();
1737 let config_path = dir.path().join("mars.toml");
1738 std::fs::create_dir(&config_path).unwrap();
1739
1740 let err = load_manifest(dir.path()).unwrap_err();
1741 let msg = err.to_string();
1742
1743 assert!(
1744 msg.contains("read manifest config"),
1745 "error should include operation context: {msg}"
1746 );
1747 assert!(
1748 msg.contains("mars.toml"),
1749 "error should include config path: {msg}"
1750 );
1751 }
1752
1753 #[test]
1754 fn load_local_missing_returns_default() {
1755 let dir = TempDir::new().unwrap();
1756 let local = load_local(dir.path()).unwrap();
1757 assert!(local.overrides.is_empty());
1758 }
1759
1760 #[test]
1761 fn load_local_from_disk() {
1762 let dir = TempDir::new().unwrap();
1763 let toml_str = r#"
1764[overrides.base]
1765path = "/home/dev/local-base"
1766"#;
1767 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1768 let local = load_local(dir.path()).unwrap();
1769 assert_eq!(local.overrides.len(), 1);
1770 assert_eq!(
1771 local.overrides["base"].path,
1772 PathBuf::from("/home/dev/local-base")
1773 );
1774 }
1775
1776 #[test]
1777 fn parse_agent_overlay_and_settings_model_policies() {
1778 let config: Config = toml::from_str(
1779 r#"
1780[agents.tech-lead]
1781description = "Tech lead overlay"
1782model = "gpt55"
1783harness = "codex"
1784effort = "medium"
1785approval = "default"
1786sandbox = "default"
1787autocompact = 1200
1788autocompact_pct = 80
1789user_invocable = false
1790model-invocable = true
1791tools.disallowed = ["Bash(rm *)", "Write"]
1792
1793[[agents.tech-lead.model-policies]]
1794match = { alias = "gpt55" }
1795override = { harness = "opencode", effort = "low" }
1796no-fallback = true
1797
1798[settings]
1799
1800[[settings.model-policies]]
1801match = { model-glob = "gpt-*" }
1802override = { effort = "high" }
1803"#,
1804 )
1805 .unwrap();
1806
1807 let overlay = config.agents.get("tech-lead").expect("tech-lead overlay");
1808 assert_eq!(overlay.description.as_deref(), Some("Tech lead overlay"));
1809 assert_eq!(overlay.model.as_deref(), Some("gpt55"));
1810 assert_eq!(overlay.harness.as_deref(), Some("codex"));
1811 assert_eq!(overlay.autocompact, Some(1200));
1812 assert_eq!(overlay.autocompact_pct, Some(80));
1813 assert_eq!(overlay.user_invocable, Some(false));
1814 assert_eq!(overlay.model_invocable, Some(true));
1815 assert_eq!(
1816 overlay.tools.disallowed,
1817 vec!["Bash(rm *)".to_string(), "Write".to_string()]
1818 );
1819 assert_eq!(overlay.model_policies.len(), 1);
1820 assert_eq!(
1821 overlay.model_policies[0].match_type,
1822 ModelPolicyMatchType::Alias
1823 );
1824 assert_eq!(overlay.model_policies[0].match_value, "gpt55");
1825 assert!(overlay.model_policies[0].no_fallback);
1826
1827 assert_eq!(config.settings.model_policies.len(), 1);
1828 assert_eq!(
1829 config.settings.model_policies[0].match_type,
1830 ModelPolicyMatchType::ModelGlob
1831 );
1832 assert_eq!(config.settings.model_policies[0].match_value, "gpt-*");
1833 }
1834
1835 #[test]
1836 fn parse_skill_overlay_tools_and_invocability() {
1837 let config: Config = toml::from_str(
1838 r#"
1839[skills.planning]
1840description = "Planning overlay"
1841user_invocable = false
1842model-invocable = true
1843tools.disallowed = ["Agent", "Write"]
1844tools.allowed = ["Bash(git *)", "mcp(plugin:demo)"]
1845"#,
1846 )
1847 .unwrap();
1848
1849 let overlay = config.skills.get("planning").expect("planning overlay");
1850 assert_eq!(overlay.description.as_deref(), Some("Planning overlay"));
1851 assert_eq!(overlay.user_invocable, Some(false));
1852 assert_eq!(overlay.model_invocable, Some(true));
1853 assert_eq!(
1854 overlay.tools.disallowed,
1855 vec!["Agent".to_string(), "Write".to_string()]
1856 );
1857 assert_eq!(
1858 overlay.tools.allowed,
1859 vec!["Bash(git *)".to_string(), "mcp(plugin:demo)".to_string()]
1860 );
1861 }
1862
1863 #[test]
1864 fn merged_skill_overlays_local_replaces_fields() {
1865 let mut base_skills = IndexMap::new();
1866 base_skills.insert(
1867 "planning".to_string(),
1868 SkillOverlay {
1869 description: Some("Base description".to_string()),
1870 user_invocable: Some(true),
1871 ..SkillOverlay::default()
1872 },
1873 );
1874
1875 let mut local_skills = IndexMap::new();
1876 local_skills.insert(
1877 "planning".to_string(),
1878 SkillOverlay {
1879 description: Some("Local description".to_string()),
1880 user_invocable: Some(false),
1881 ..SkillOverlay::default()
1882 },
1883 );
1884 let local = LocalConfig {
1885 skills: local_skills,
1886 ..LocalConfig::default()
1887 };
1888
1889 let merged = overlay_skills_replace_by_key(&base_skills, &local.skills);
1890 let replaced = merged.get("planning").expect("planning overlay");
1891 assert_eq!(replaced.description.as_deref(), Some("Local description"));
1892 assert_eq!(replaced.user_invocable, Some(false));
1893 }
1894
1895 #[test]
1896 fn merged_agent_overlays_local_replaces_widened_fields() {
1897 let mut base_agents = IndexMap::new();
1898 base_agents.insert(
1899 "worker".to_string(),
1900 AgentOverlay {
1901 description: Some("Base description".to_string()),
1902 user_invocable: Some(true),
1903 ..AgentOverlay::default()
1904 },
1905 );
1906
1907 let mut local_agents = IndexMap::new();
1908 local_agents.insert(
1909 "worker".to_string(),
1910 AgentOverlay {
1911 description: Some("Local description".to_string()),
1912 user_invocable: Some(false),
1913 ..AgentOverlay::default()
1914 },
1915 );
1916 let local = LocalConfig {
1917 agents: local_agents,
1918 ..LocalConfig::default()
1919 };
1920
1921 let merged = merged_agent_overlays(&base_agents, &local);
1922 let replaced = merged.get("worker").expect("worker overlay");
1923 assert_eq!(replaced.description.as_deref(), Some("Local description"));
1924 assert_eq!(replaced.user_invocable, Some(false));
1925 }
1926
1927 #[test]
1928 fn merged_agent_overlays_replace_by_agent_name() {
1929 let mut base_agents = IndexMap::new();
1930 base_agents.insert(
1931 "tech-lead".to_string(),
1932 AgentOverlay {
1933 model: Some("gpt55".to_string()),
1934 harness: Some("codex".to_string()),
1935 effort: Some("high".to_string()),
1936 ..AgentOverlay::default()
1937 },
1938 );
1939 base_agents.insert(
1940 "reviewer".to_string(),
1941 AgentOverlay {
1942 model: Some("gpt-5.4-mini".to_string()),
1943 ..AgentOverlay::default()
1944 },
1945 );
1946
1947 let mut local_agents = IndexMap::new();
1948 local_agents.insert(
1949 "tech-lead".to_string(),
1950 AgentOverlay {
1951 model: Some("gptmini".to_string()),
1952 ..AgentOverlay::default()
1953 },
1954 );
1955 let local = LocalConfig {
1956 agents: local_agents,
1957 ..LocalConfig::default()
1958 };
1959
1960 let merged = merged_agent_overlays(&base_agents, &local);
1961 let replaced = merged.get("tech-lead").expect("tech-lead should exist");
1962 assert_eq!(replaced.model.as_deref(), Some("gptmini"));
1963 assert!(
1964 replaced.harness.is_none(),
1965 "local overlay must replace the base overlay block"
1966 );
1967 assert!(
1968 replaced.effort.is_none(),
1969 "local overlay replacement must not deep-merge base fields"
1970 );
1971 assert_eq!(
1972 merged
1973 .get("reviewer")
1974 .and_then(|overlay| overlay.model.as_deref()),
1975 Some("gpt-5.4-mini")
1976 );
1977 }
1978
1979 #[test]
1980 fn merged_settings_applies_local_routing_overrides() {
1981 let settings = Settings {
1982 default_harness: Some("claude".to_string()),
1983 harness_order: Some(vec!["claude".to_string(), "pi".to_string()]),
1984 provider_order: Some(vec!["anthropic".to_string()]),
1985 ..Settings::default()
1986 };
1987 let local = LocalConfig {
1988 settings: LocalSettings {
1989 default_harness: Some("cursor".to_string()),
1990 default_model: Some("gpt-5".to_string()),
1991 harness_order: Some(vec!["cursor".to_string(), "pi".to_string()]),
1992 provider_order: Some(vec!["openai".to_string()]),
1993 model_policies: None,
1994 ..LocalSettings::default()
1995 },
1996 ..LocalConfig::default()
1997 };
1998
1999 let merged = merged_settings(&settings, &local);
2000 assert_eq!(merged.default_harness.as_deref(), Some("cursor"));
2001 assert_eq!(merged.default_model.as_deref(), Some("gpt-5"));
2002 assert_eq!(
2003 merged.harness_order,
2004 Some(vec!["cursor".to_string(), "pi".to_string()])
2005 );
2006 assert_eq!(merged.provider_order, Some(vec!["openai".to_string()]));
2007 }
2008
2009 #[test]
2010 fn merged_settings_overlays_model_visibility_keys_independently() {
2011 let settings = Settings {
2012 model_visibility: ModelVisibility {
2013 include: Some(vec!["openai/*".to_string()]),
2014 exclude: Some(vec!["*-preview".to_string()]),
2015 },
2016 ..Settings::default()
2017 };
2018 let local = LocalConfig {
2019 settings: LocalSettings {
2020 model_visibility: Some(LocalModelVisibility {
2021 include: Some(vec!["anthropic/*".to_string()]),
2022 exclude: None,
2023 }),
2024 ..LocalSettings::default()
2025 },
2026 ..LocalConfig::default()
2027 };
2028
2029 let merged = merged_settings(&settings, &local);
2030 assert_eq!(
2031 merged.model_visibility.include,
2032 Some(vec!["anthropic/*".to_string()])
2033 );
2034 assert_eq!(
2035 merged.model_visibility.exclude,
2036 Some(vec!["*-preview".to_string()])
2037 );
2038 }
2039
2040 #[test]
2041 fn merged_settings_replaces_scalar_table_and_array_fields() {
2042 let base_rule = ModelPolicyRule {
2043 match_type: ModelPolicyMatchType::Alias,
2044 match_value: "gpt55".to_string(),
2045 no_fallback: false,
2046 overrides: serde_yaml::Mapping::new(),
2047 };
2048 let local_rule = ModelPolicyRule {
2049 match_type: ModelPolicyMatchType::Alias,
2050 match_value: "gptmini".to_string(),
2051 no_fallback: false,
2052 overrides: serde_yaml::Mapping::new(),
2053 };
2054 let settings = Settings {
2055 targets: Some(vec![".claude".to_string(), ".codex".to_string()]),
2056 model_visibility: ModelVisibility {
2057 include: Some(vec!["anthropic/*".to_string()]),
2058 exclude: None,
2059 },
2060 models_cache_ttl_hours: 24,
2061 min_mars_version: Some("0.1.0".to_string()),
2062 model_policies: vec![base_rule],
2063 ..Settings::default()
2064 };
2065 let local = LocalConfig {
2066 settings: LocalSettings {
2067 targets: Some(vec![".cursor".to_string()]),
2068 model_visibility: Some(LocalModelVisibility {
2069 include: None,
2070 exclude: Some(vec!["*-preview*".to_string()]),
2071 }),
2072 models_cache_ttl_hours: Some(48),
2073 min_mars_version: Some("0.2.0".to_string()),
2074 model_policies: Some(vec![local_rule.clone()]),
2075 ..LocalSettings::default()
2076 },
2077 ..LocalConfig::default()
2078 };
2079
2080 let merged = merged_settings(&settings, &local);
2081 assert_eq!(merged.targets, Some(vec![".cursor".to_string()]));
2082 assert_eq!(merged.models_cache_ttl_hours, 48);
2083 assert_eq!(merged.min_mars_version.as_deref(), Some("0.2.0"));
2084 assert_eq!(
2085 merged.model_visibility.exclude,
2086 Some(vec!["*-preview*".to_string()])
2087 );
2088 assert_eq!(
2089 merged.model_visibility.include,
2090 Some(vec!["anthropic/*".to_string()])
2091 );
2092 assert_eq!(merged.model_policies, vec![local_rule]);
2093 }
2094
2095 #[test]
2096 fn merged_models_local_overlay_replaces_by_key_and_inherits_others() {
2097 let mut base = IndexMap::new();
2098 base.insert(
2099 "fast".to_string(),
2100 ModelAlias {
2101 harness: Some("codex".to_string()),
2102 description: Some("base".to_string()),
2103 prompting: None,
2104 default_effort: None,
2105 autocompact: None,
2106 autocompact_pct: None,
2107 spec: ModelSpec::Pinned {
2108 model: "gpt-5".to_string(),
2109 provider: None,
2110 },
2111 },
2112 );
2113 base.insert(
2114 "legacy".to_string(),
2115 ModelAlias {
2116 harness: Some("claude".to_string()),
2117 description: None,
2118 prompting: None,
2119 default_effort: None,
2120 autocompact: None,
2121 autocompact_pct: None,
2122 spec: ModelSpec::Pinned {
2123 model: "claude-opus-4-6".to_string(),
2124 provider: None,
2125 },
2126 },
2127 );
2128
2129 let local = LocalConfig {
2130 models: {
2131 let mut m = IndexMap::new();
2132 m.insert(
2133 "fast".to_string(),
2134 ModelAlias {
2135 harness: Some("cursor".to_string()),
2136 description: Some("local".to_string()),
2137 prompting: None,
2138 default_effort: None,
2139 autocompact: None,
2140 autocompact_pct: None,
2141 spec: ModelSpec::Pinned {
2142 model: "gpt-5.4-mini".to_string(),
2143 provider: None,
2144 },
2145 },
2146 );
2147 m.insert(
2148 "local-only".to_string(),
2149 ModelAlias {
2150 harness: Some("pi".to_string()),
2151 description: None,
2152 prompting: None,
2153 default_effort: None,
2154 autocompact: None,
2155 autocompact_pct: None,
2156 spec: ModelSpec::Pinned {
2157 model: "gpt-5.5".to_string(),
2158 provider: None,
2159 },
2160 },
2161 );
2162 m
2163 },
2164 ..LocalConfig::default()
2165 };
2166
2167 let merged = merged_models(&base, &local);
2168 assert_eq!(merged.len(), 3);
2169 assert_eq!(merged["fast"].harness.as_deref(), Some("cursor"));
2170 assert_eq!(
2171 merged["fast"].description.as_deref(),
2172 Some("local"),
2173 "local value should replace by key"
2174 );
2175 assert_eq!(merged["legacy"].harness.as_deref(), Some("claude"));
2176 assert_eq!(merged["local-only"].harness.as_deref(), Some("pi"));
2177 }
2178
2179 #[test]
2180 fn load_local_rejects_unknown_local_settings_fields() {
2181 let dir = TempDir::new().unwrap();
2182 std::fs::write(
2183 dir.path().join("mars.local.toml"),
2184 "[settings]\nnot_supported = true\n",
2185 )
2186 .unwrap();
2187
2188 let err = load_local(dir.path()).unwrap_err().to_string();
2189 assert!(err.contains("unknown field"), "unexpected error: {err}");
2190 assert!(err.contains("not_supported"), "unexpected error: {err}");
2191 }
2192
2193 #[test]
2194 fn load_local_rejects_unknown_local_model_visibility_fields() {
2195 let dir = TempDir::new().unwrap();
2196 std::fs::write(
2197 dir.path().join("mars.local.toml"),
2198 "[settings.model_visibility]\nfuture_nested_key = true\n",
2199 )
2200 .unwrap();
2201
2202 let err = load_local(dir.path()).unwrap_err().to_string();
2203 assert!(err.contains("unknown field"), "unexpected error: {err}");
2204 assert!(err.contains("future_nested_key"), "unexpected error: {err}");
2205 }
2206
2207 #[test]
2208 fn load_effective_project_config_accepts_unknown_project_settings_keys() {
2209 let dir = TempDir::new().unwrap();
2210 std::fs::write(
2211 dir.path().join("mars.toml"),
2212 r#"[settings]
2213harness_order = ["codex", "pi"]
2214future_key = "allowed"
2215
2216[settings.model_visibility]
2217include = ["openai/*"]
2218future_nested_key = true
2219"#,
2220 )
2221 .unwrap();
2222
2223 let effective = load_effective_project_config(dir.path())
2224 .expect("project settings overlay should ignore unknown keys");
2225 assert_eq!(
2226 effective.settings.harness_order,
2227 Some(vec!["codex".to_string(), "pi".to_string()])
2228 );
2229 assert_eq!(
2230 effective.settings.model_visibility.include,
2231 Some(vec!["openai/*".to_string()])
2232 );
2233 }
2234
2235 #[test]
2236 fn merge_with_empty_local() {
2237 let config = Config {
2238 dependencies: {
2239 let mut m = IndexMap::new();
2240 m.insert(
2241 "base".into(),
2242 DependencyEntry {
2243 url: Some("https://github.com/org/base.git".into()),
2244 path: None,
2245 subpath: None,
2246 version: Some("v1.0".into()),
2247 dialect: None,
2248 filter: FilterConfig::default(),
2249 },
2250 );
2251 m
2252 },
2253 settings: Settings::default(),
2254 ..Config::default()
2255 };
2256 let local = LocalConfig::default();
2257 let effective = merge(config, local).unwrap();
2258 assert_eq!(effective.dependencies.len(), 1);
2259 let source = &effective.dependencies["base"];
2260 match &source.spec {
2261 SourceSpec::Git(git) => {
2262 assert_eq!(git.url, "https://github.com/org/base.git");
2263 assert_eq!(git.version.as_deref(), Some("v1.0"));
2264 }
2265 SourceSpec::Path(_) => panic!("expected Git"),
2266 }
2267 }
2268
2269 #[test]
2270 fn merge_override_replaces_with_path() {
2271 let config = Config {
2272 dependencies: {
2273 let mut m = IndexMap::new();
2274 m.insert(
2275 "base".into(),
2276 DependencyEntry {
2277 url: Some("https://github.com/org/base.git".into()),
2278 path: None,
2279 subpath: None,
2280 version: Some("v1.0".into()),
2281 dialect: None,
2282 filter: FilterConfig::default(),
2283 },
2284 );
2285 m
2286 },
2287 settings: Settings::default(),
2288 ..Config::default()
2289 };
2290 let local = LocalConfig {
2291 overrides: {
2292 let mut m = IndexMap::new();
2293 m.insert(
2294 "base".into(),
2295 OverrideEntry {
2296 path: PathBuf::from("/home/dev/local-base"),
2297 },
2298 );
2299 m
2300 },
2301 ..LocalConfig::default()
2302 };
2303 let effective = merge(config, local).unwrap();
2304 let source = &effective.dependencies["base"];
2305 match &source.spec {
2306 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
2307 SourceSpec::Git(_) => panic!("expected Path override"),
2308 }
2309 }
2310
2311 #[test]
2312 fn merge_override_retains_subpath_coordinate() {
2313 let temp = TempDir::new().unwrap();
2314 let temp_root = dunce::canonicalize(temp.path()).unwrap();
2316 let override_path = temp_root.join("local-base");
2317 std::fs::create_dir_all(&override_path).unwrap();
2318 let canonical_override = dunce::canonicalize(&override_path).unwrap();
2319
2320 let config = Config {
2321 dependencies: {
2322 let mut m = IndexMap::new();
2323 m.insert(
2324 "base".into(),
2325 DependencyEntry {
2326 url: Some("https://github.com/org/base.git".into()),
2327 path: None,
2328 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
2329 version: Some("v1.0".into()),
2330 dialect: None,
2331 filter: FilterConfig::default(),
2332 },
2333 );
2334 m
2335 },
2336 settings: Settings::default(),
2337 ..Config::default()
2338 };
2339 let local = LocalConfig {
2340 overrides: {
2341 let mut m = IndexMap::new();
2342 m.insert(
2343 "base".into(),
2344 OverrideEntry {
2345 path: canonical_override.clone(),
2346 },
2347 );
2348 m
2349 },
2350 ..LocalConfig::default()
2351 };
2352
2353 let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
2354 let source = &effective.dependencies["base"];
2355 assert_eq!(
2356 source.subpath.as_ref().map(SourceSubpath::as_str),
2357 Some("plugins/foo")
2358 );
2359 assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
2360 assert!(matches!(
2361 &source.source_id,
2362 SourceId::Path {
2363 canonical,
2364 subpath: Some(sp)
2365 } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
2366 ));
2367 }
2368
2369 #[test]
2370 fn merge_all_filter_mode() {
2371 let config = Config {
2372 dependencies: {
2373 let mut m = IndexMap::new();
2374 m.insert(
2375 "base".into(),
2376 DependencyEntry {
2377 url: Some("https://github.com/org/base.git".into()),
2378 path: None,
2379 subpath: None,
2380 version: None,
2381 dialect: None,
2382 filter: FilterConfig::default(),
2383 },
2384 );
2385 m
2386 },
2387 settings: Settings::default(),
2388 ..Config::default()
2389 };
2390 let effective = merge(config, LocalConfig::default()).unwrap();
2391 assert!(matches!(
2392 effective.dependencies["base"].filter,
2393 FilterMode::All
2394 ));
2395 }
2396
2397 #[test]
2398 fn save_and_reload() {
2399 let dir = TempDir::new().unwrap();
2400 let config = Config {
2401 dependencies: {
2402 let mut m = IndexMap::new();
2403 m.insert(
2404 "base".into(),
2405 DependencyEntry {
2406 url: Some("https://github.com/org/base.git".into()),
2407 path: None,
2408 subpath: None,
2409 version: Some("v2.0".into()),
2410 dialect: None,
2411 filter: FilterConfig::default(),
2412 },
2413 );
2414 m
2415 },
2416 settings: Settings::default(),
2417 ..Config::default()
2418 };
2419 save(dir.path(), &config).unwrap();
2420 let reloaded = load(dir.path()).unwrap();
2421 assert_eq!(config, reloaded);
2422 }
2423
2424 #[test]
2425 fn rename_map_preserved() {
2426 let toml_str = r#"
2427[dependencies.base]
2428url = "https://github.com/org/base.git"
2429
2430[dependencies.base.rename]
2431old-name = "new-name"
2432"#;
2433 let config: Config = toml::from_str(toml_str).unwrap();
2434 let effective = merge(config, LocalConfig::default()).unwrap();
2435 let source = &effective.dependencies["base"];
2436 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
2437 }
2438
2439 #[test]
2440 fn self_dependency_name_rejected() {
2441 let toml_str = r#"
2442[dependencies._self]
2443url = "https://github.com/org/base.git"
2444"#;
2445 let config: Config = toml::from_str(toml_str).unwrap();
2446 let local = LocalConfig::default();
2447 let result = merge(config, local);
2448 assert!(result.is_err());
2449 let err = result.unwrap_err().to_string();
2450 assert!(
2451 err.contains("_self") && err.contains("reserved"),
2452 "should reject _self: {err}"
2453 );
2454 }
2455
2456 #[test]
2457 fn managed_root_setting_roundtrip() {
2458 let config = Config {
2459 settings: Settings {
2460 managed_root: Some(".claude".into()),
2461 targets: None,
2462 ..Settings::default()
2463 },
2464 ..Config::default()
2465 };
2466 let serialized = toml::to_string_pretty(&config).unwrap();
2467 let deserialized: Config = toml::from_str(&serialized).unwrap();
2468 assert_eq!(
2469 deserialized.settings.managed_root.as_deref(),
2470 Some(".claude")
2471 );
2472 }
2473
2474 #[test]
2475 fn save_preserves_dependencies_when_clearing_last_target() {
2476 let dir = TempDir::new().unwrap();
2477 let original = r#"
2478[package]
2479name = "sample"
2480version = "0.1.0"
2481
2482[dependencies.base]
2483url = "https://github.com/org/base.git"
2484version = "v1.0"
2485agents = ["coder"]
2486
2487[settings]
2488managed_root = ".agents"
2489targets = [".claude"]
2490"#;
2491 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2492
2493 let mut config = load(dir.path()).unwrap();
2494 if let Some(targets) = config.settings.targets.as_mut() {
2495 targets.retain(|target| target != ".claude");
2496 if targets.is_empty() {
2497 config.settings.targets = None;
2498 }
2499 }
2500 save(dir.path(), &config).unwrap();
2501
2502 let reloaded = load(dir.path()).unwrap();
2503 assert_eq!(
2504 reloaded.package.as_ref().map(|p| p.name.as_str()),
2505 Some("sample")
2506 );
2507 assert_eq!(
2508 reloaded.dependencies["base"].url.as_deref(),
2509 Some("https://github.com/org/base.git")
2510 );
2511 assert_eq!(
2512 reloaded.dependencies["base"].version.as_deref(),
2513 Some("v1.0")
2514 );
2515 assert_eq!(
2516 reloaded.dependencies["base"].filter.agents.as_deref(),
2517 Some(&["coder".into()][..])
2518 );
2519 assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
2520 assert!(reloaded.settings.targets.is_none());
2521 }
2522
2523 #[test]
2524 fn roundtrip_preserves_all_filter_fields() {
2525 let dir = TempDir::new().unwrap();
2526 let original = r#"
2527[dependencies.include]
2528url = "https://github.com/org/include.git"
2529agents = ["coder", "reviewer"]
2530skills = ["review", "plan"]
2531
2532[dependencies.include.rename]
2533coder = "core-coder"
2534
2535[dependencies.exclude]
2536url = "https://github.com/org/exclude.git"
2537exclude = ["experimental", "deprecated"]
2538
2539[dependencies.only_skills]
2540url = "https://github.com/org/skills.git"
2541only_skills = true
2542
2543[dependencies.only_agents]
2544url = "https://github.com/org/agents.git"
2545only_agents = true
2546"#;
2547 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2548
2549 let config = load(dir.path()).unwrap();
2550 save(dir.path(), &config).unwrap();
2551 let reloaded = load(dir.path()).unwrap();
2552
2553 let include = &reloaded.dependencies["include"].filter;
2554 assert_eq!(
2555 include.agents.as_deref(),
2556 Some(&["coder".into(), "reviewer".into()][..])
2557 );
2558 assert_eq!(
2559 include.skills.as_deref(),
2560 Some(&["review".into(), "plan".into()][..])
2561 );
2562 assert_eq!(
2563 include.rename.as_ref().and_then(|r| r.get("coder")),
2564 Some(&"core-coder".into())
2565 );
2566
2567 let exclude = &reloaded.dependencies["exclude"].filter;
2568 assert_eq!(
2569 exclude.exclude.as_deref(),
2570 Some(&["experimental".into(), "deprecated".into()][..])
2571 );
2572
2573 let only_skills = &reloaded.dependencies["only_skills"].filter;
2574 assert!(only_skills.only_skills);
2575 assert!(!only_skills.only_agents);
2576
2577 let only_agents = &reloaded.dependencies["only_agents"].filter;
2578 assert!(only_agents.only_agents);
2579 assert!(!only_agents.only_skills);
2580 }
2581
2582 #[test]
2583 fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
2584 let dir = TempDir::new().unwrap();
2585 let original = r#"
2586[dependencies.git-include]
2587url = "https://github.com/org/git-include.git"
2588agents = ["coder"]
2589
2590[dependencies.path-exclude]
2591path = "../local-source"
2592exclude = ["draft"]
2593
2594[dependencies.git-only-skills]
2595url = "https://github.com/org/git-skills.git"
2596only_skills = true
2597
2598[dependencies.git-only-agents]
2599url = "https://github.com/org/git-agents.git"
2600only_agents = true
2601"#;
2602 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2603
2604 let config = load(dir.path()).unwrap();
2605 save(dir.path(), &config).unwrap();
2606 let reloaded = load(dir.path()).unwrap();
2607
2608 assert_eq!(reloaded.dependencies.len(), 4);
2609 assert_eq!(
2610 reloaded.dependencies["git-include"]
2611 .filter
2612 .agents
2613 .as_deref(),
2614 Some(&["coder".into()][..])
2615 );
2616 assert_eq!(
2617 reloaded.dependencies["path-exclude"].path.as_deref(),
2618 Some(Path::new("../local-source"))
2619 );
2620 assert_eq!(
2621 reloaded.dependencies["path-exclude"]
2622 .filter
2623 .exclude
2624 .as_deref(),
2625 Some(&["draft".into()][..])
2626 );
2627 assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
2628 assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
2629 }
2630
2631 #[test]
2632 fn save_roundtrip_guard_rejects_dependency_count_loss() {
2633 let mut original = Config::default();
2634 original.dependencies.insert(
2635 "base".into(),
2636 DependencyEntry {
2637 url: Some("https://github.com/org/base.git".into()),
2638 path: None,
2639 subpath: None,
2640 version: Some("v1.0".into()),
2641 dialect: None,
2642 filter: FilterConfig::default(),
2643 },
2644 );
2645
2646 let reparsed = Config::default();
2647 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2648 let msg = err.to_string();
2649 assert!(
2650 msg.contains("dependency count changed"),
2651 "unexpected error: {msg}"
2652 );
2653 }
2654
2655 #[test]
2656 fn save_roundtrip_guard_rejects_managed_root_loss() {
2657 let original = Config {
2658 settings: Settings {
2659 managed_root: Some(".agents".into()),
2660 targets: None,
2661 ..Settings::default()
2662 },
2663 ..Config::default()
2664 };
2665 let reparsed = Config::default();
2666 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2667 let msg = err.to_string();
2668 assert!(
2669 msg.contains("settings.managed_root changed"),
2670 "unexpected error: {msg}"
2671 );
2672 }
2673
2674 #[test]
2675 fn save_roundtrip_guard_rejects_harness_order_loss() {
2676 let original = Config {
2677 settings: Settings {
2678 harness_order: Some(vec!["pi".into(), "codex".into()]),
2679 ..Settings::default()
2680 },
2681 ..Config::default()
2682 };
2683 let reparsed = Config::default();
2684 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2685 let msg = err.to_string();
2686 assert!(
2687 msg.contains("settings.harness_order changed"),
2688 "unexpected error: {msg}"
2689 );
2690 }
2691
2692 #[test]
2693 fn parse_only_skills_filter() {
2694 let toml_str = r#"
2695[dependencies.base]
2696url = "https://github.com/org/base.git"
2697only_skills = true
2698"#;
2699 let config: Config = toml::from_str(toml_str).unwrap();
2700 let local = LocalConfig::default();
2701 let effective = merge(config, local).unwrap();
2702 let source = &effective.dependencies["base"];
2703 assert!(matches!(source.filter, FilterMode::OnlySkills));
2704 }
2705
2706 #[test]
2707 fn parse_only_agents_filter() {
2708 let toml_str = r#"
2709[dependencies.base]
2710url = "https://github.com/org/base.git"
2711only_agents = true
2712"#;
2713 let config: Config = toml::from_str(toml_str).unwrap();
2714 let local = LocalConfig::default();
2715 let effective = merge(config, local).unwrap();
2716 let source = &effective.dependencies["base"];
2717 assert!(matches!(source.filter, FilterMode::OnlyAgents));
2718 }
2719
2720 #[test]
2721 fn error_on_only_skills_and_only_agents() {
2722 let toml_str = r#"
2723[dependencies.bad]
2724url = "https://github.com/org/bad.git"
2725only_skills = true
2726only_agents = true
2727"#;
2728 let config: Config = toml::from_str(toml_str).unwrap();
2729 let local = LocalConfig::default();
2730 let result = merge(config, local);
2731 assert!(result.is_err());
2732 let err = result.unwrap_err().to_string();
2733 assert!(
2734 err.contains("mutually exclusive"),
2735 "should mention mutually exclusive: {err}"
2736 );
2737 }
2738
2739 #[test]
2740 fn error_on_only_skills_with_agents_list() {
2741 let toml_str = r#"
2742[dependencies.bad]
2743url = "https://github.com/org/bad.git"
2744only_skills = true
2745agents = ["coder"]
2746"#;
2747 let config: Config = toml::from_str(toml_str).unwrap();
2748 let local = LocalConfig::default();
2749 let result = merge(config, local);
2750 assert!(result.is_err());
2751 let err = result.unwrap_err().to_string();
2752 assert!(
2753 err.contains("cannot combine"),
2754 "should mention cannot combine: {err}"
2755 );
2756 }
2757
2758 #[test]
2759 fn error_on_only_agents_with_skills_list() {
2760 let toml_str = r#"
2761[dependencies.bad]
2762url = "https://github.com/org/bad.git"
2763only_agents = true
2764skills = ["planning"]
2765"#;
2766 let config: Config = toml::from_str(toml_str).unwrap();
2767 let local = LocalConfig::default();
2768 let result = merge(config, local);
2769 assert!(result.is_err());
2770 }
2771
2772 #[test]
2773 fn error_on_only_skills_with_exclude() {
2774 let toml_str = r#"
2775[dependencies.bad]
2776url = "https://github.com/org/bad.git"
2777only_skills = true
2778exclude = ["deprecated"]
2779"#;
2780 let config: Config = toml::from_str(toml_str).unwrap();
2781 let local = LocalConfig::default();
2782 let result = merge(config, local);
2783 assert!(result.is_err());
2784 }
2785
2786 #[test]
2787 fn only_skills_false_not_serialized() {
2788 let config = Config {
2789 dependencies: {
2790 let mut m = IndexMap::new();
2791 m.insert(
2792 "base".into(),
2793 DependencyEntry {
2794 url: Some("https://github.com/org/base.git".into()),
2795 path: None,
2796 subpath: None,
2797 version: None,
2798 dialect: None,
2799 filter: FilterConfig::default(),
2800 },
2801 );
2802 m
2803 },
2804 settings: Settings::default(),
2805 ..Config::default()
2806 };
2807 let serialized = toml::to_string_pretty(&config).unwrap();
2808 assert!(
2809 !serialized.contains("only_skills"),
2810 "false booleans should not be serialized: {serialized}"
2811 );
2812 assert!(
2813 !serialized.contains("only_agents"),
2814 "false booleans should not be serialized: {serialized}"
2815 );
2816 }
2817
2818 #[test]
2819 fn only_skills_true_roundtrips() {
2820 let toml_str = r#"
2821[dependencies.base]
2822url = "https://github.com/org/base.git"
2823only_skills = true
2824"#;
2825 let config: Config = toml::from_str(toml_str).unwrap();
2826 assert!(config.dependencies["base"].filter.only_skills);
2827 assert!(!config.dependencies["base"].filter.only_agents);
2828
2829 let serialized = toml::to_string_pretty(&config).unwrap();
2830 let reloaded: Config = toml::from_str(&serialized).unwrap();
2831 assert!(reloaded.dependencies["base"].filter.only_skills);
2832 }
2833
2834 #[test]
2835 fn filter_config_has_any_filter() {
2836 assert!(!FilterConfig::default().has_any_filter());
2837 assert!(
2838 FilterConfig {
2839 only_skills: true,
2840 ..FilterConfig::default()
2841 }
2842 .has_any_filter()
2843 );
2844 assert!(
2845 FilterConfig {
2846 agents: Some(vec!["coder".into()]),
2847 ..FilterConfig::default()
2848 }
2849 .has_any_filter()
2850 );
2851 }
2852
2853 #[test]
2854 fn filter_config_to_mode() {
2855 assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
2856 assert!(matches!(
2857 FilterConfig {
2858 only_skills: true,
2859 ..FilterConfig::default()
2860 }
2861 .to_mode(),
2862 FilterMode::OnlySkills
2863 ));
2864 assert!(matches!(
2865 FilterConfig {
2866 only_agents: true,
2867 ..FilterConfig::default()
2868 }
2869 .to_mode(),
2870 FilterMode::OnlyAgents
2871 ));
2872 assert!(matches!(
2873 FilterConfig {
2874 agents: Some(vec!["coder".into()]),
2875 ..FilterConfig::default()
2876 }
2877 .to_mode(),
2878 FilterMode::Include { .. }
2879 ));
2880 assert!(matches!(
2881 FilterConfig {
2882 exclude: Some(vec!["old".into()]),
2883 ..FilterConfig::default()
2884 }
2885 .to_mode(),
2886 FilterMode::Exclude(_)
2887 ));
2888 }
2889
2890 #[test]
2893 fn managed_targets_defaults_to_no_target_sync_targets() {
2894 let settings = Settings::default();
2895 assert!(settings.managed_targets().is_empty());
2896 }
2897
2898 #[test]
2899 fn managed_targets_uses_explicit_targets() {
2900 let settings = Settings {
2901 targets: Some(vec![".claude".to_string()]),
2902 ..Settings::default()
2903 };
2904 assert_eq!(settings.managed_targets(), vec![".claude"]);
2905 }
2906
2907 #[test]
2908 fn managed_targets_uses_managed_root_as_primary() {
2909 let settings = Settings {
2910 managed_root: Some(".claude".to_string()),
2911 ..Settings::default()
2912 };
2913 assert_eq!(settings.managed_targets(), vec![".claude"]);
2914 }
2915
2916 #[test]
2917 fn managed_targets_explicit_overrides_links_and_managed_root() {
2918 let settings = Settings {
2919 managed_root: Some(".cursor".to_string()),
2920 targets: Some(vec![".codex".to_string()]),
2921 ..Settings::default()
2922 };
2923 assert_eq!(settings.managed_targets(), vec![".codex"]);
2925 }
2926
2927 #[test]
2928 fn managed_targets_normalizes_bare_harness_and_generic_links() {
2929 let settings = Settings {
2930 targets: Some(vec![
2931 "codex".to_string(),
2932 "agents".to_string(),
2933 "foo".to_string(),
2934 ]),
2935 ..Settings::default()
2936 };
2937 assert_eq!(
2938 settings.managed_targets(),
2939 vec![
2940 ".codex".to_string(),
2941 ".agents".to_string(),
2942 ".foo".to_string()
2943 ]
2944 );
2945 }
2946
2947 #[test]
2948 fn linked_harnesses_extracts_legacy_path_form_harness_links() {
2949 let settings = Settings {
2950 targets: Some(vec![
2951 ".codex".to_string(),
2952 ".claude".to_string(),
2953 ".agents".to_string(),
2954 ]),
2955 ..Settings::default()
2956 };
2957 assert_eq!(
2958 settings.linked_harnesses(),
2959 vec!["codex".to_string(), "claude".to_string()]
2960 );
2961 }
2962
2963 #[test]
2964 fn merge_warns_when_managed_root_is_agents() {
2965 let config = Config {
2966 settings: Settings {
2967 managed_root: Some(".agents".into()),
2968 ..Settings::default()
2969 },
2970 ..Config::default()
2971 };
2972
2973 let (_, diagnostics) =
2974 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
2975
2976 assert!(diagnostics.iter().any(|diag| {
2977 diag.code == "deprecated-agents-target"
2978 && diag.context.as_deref() == Some("settings.managed_root")
2979 }));
2980 }
2981
2982 #[test]
2983 fn merge_warns_when_targets_include_agents() {
2984 let config = Config {
2985 settings: Settings {
2986 targets: Some(vec![".agents".into(), ".claude".into()]),
2987 ..Settings::default()
2988 },
2989 ..Config::default()
2990 };
2991
2992 let (_, diagnostics) =
2993 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
2994
2995 assert!(diagnostics.iter().any(|diag| {
2996 diag.code == "deprecated-agents-target"
2997 && diag.context.as_deref() == Some("settings.targets")
2998 }));
2999 }
3000
3001 #[test]
3002 fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
3003 let config: Config = toml::from_str(
3004 r#"
3005[dependencies.base]
3006url = "https://github.com/org/base.git"
3007"#,
3008 )
3009 .unwrap();
3010 assert_eq!(config.settings.models_cache_ttl_hours, 24);
3011 }
3012
3013 #[test]
3014 fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
3015 let config: Config = toml::from_str(
3016 r#"
3017[settings]
3018managed_root = ".agents"
3019"#,
3020 )
3021 .unwrap();
3022 assert_eq!(config.settings.models_cache_ttl_hours, 24);
3023 }
3024
3025 #[test]
3026 fn settings_models_cache_ttl_parses_zero() {
3027 let config: Config = toml::from_str(
3028 r#"
3029[settings]
3030models_cache_ttl_hours = 0
3031"#,
3032 )
3033 .unwrap();
3034 assert_eq!(config.settings.models_cache_ttl_hours, 0);
3035 }
3036
3037 #[test]
3038 fn settings_models_cache_ttl_parses_custom_value() {
3039 let config: Config = toml::from_str(
3040 r#"
3041[settings]
3042models_cache_ttl_hours = 48
3043"#,
3044 )
3045 .unwrap();
3046 assert_eq!(config.settings.models_cache_ttl_hours, 48);
3047 }
3048
3049 #[test]
3050 fn settings_models_cache_ttl_roundtrip_preserves_value() {
3051 let original = Config {
3052 settings: Settings {
3053 models_cache_ttl_hours: 48,
3054 ..Settings::default()
3055 },
3056 ..Config::default()
3057 };
3058 let serialized = toml::to_string_pretty(&original).unwrap();
3059 let roundtripped: Config = toml::from_str(&serialized).unwrap();
3060 assert_eq!(
3061 roundtripped.settings.models_cache_ttl_hours,
3062 original.settings.models_cache_ttl_hours
3063 );
3064 }
3065
3066 #[test]
3067 fn settings_agent_emission_parses_auto() {
3068 let config: Config = toml::from_str(
3069 r#"
3070[settings]
3071agent_emission = "auto"
3072"#,
3073 )
3074 .unwrap();
3075 assert_eq!(config.settings.agent_emission, Some(AgentEmission::Auto));
3076 }
3077
3078 #[test]
3079 fn settings_agent_emission_parses_always_and_never() {
3080 let always: Config = toml::from_str(
3081 r#"
3082[settings]
3083agent_emission = "always"
3084"#,
3085 )
3086 .unwrap();
3087 assert_eq!(always.settings.agent_emission, Some(AgentEmission::Always));
3088
3089 let never: Config = toml::from_str(
3090 r#"
3091[settings]
3092agent_emission = "never"
3093"#,
3094 )
3095 .unwrap();
3096 assert_eq!(never.settings.agent_emission, Some(AgentEmission::Never));
3097 }
3098
3099 #[test]
3100 fn settings_agent_emission_defaults_to_auto_when_omitted() {
3101 let config: Config = toml::from_str(
3102 r#"
3103[settings]
3104models_cache_ttl_hours = 48
3105"#,
3106 )
3107 .unwrap();
3108 assert!(config.settings.agent_emission.is_none());
3109 }
3110
3111 #[test]
3112 fn settings_agent_copy_parses() {
3113 let config: Config = toml::from_str(
3114 r#"
3115[settings]
3116targets = [".claude"]
3117
3118[settings.meridian.agent_copy]
3119harnesses = ["claude"]
3120include_fanout = true
3121"#,
3122 )
3123 .unwrap();
3124 let agent_copy = config
3125 .settings
3126 .meridian_agent_copy()
3127 .expect("agent_copy should parse")
3128 .clone();
3129 assert_eq!(agent_copy.harnesses, vec!["claude".to_string()]);
3130 assert!(agent_copy.include_fanout);
3131 }
3132
3133 #[test]
3134 fn settings_default_harness_parses_and_roundtrips() {
3135 let config: Config = toml::from_str(
3136 r#"
3137[settings]
3138default_harness = "codex"
3139"#,
3140 )
3141 .unwrap();
3142 assert_eq!(config.settings.default_harness.as_deref(), Some("codex"));
3143
3144 let serialized = toml::to_string_pretty(&config).unwrap();
3145 let roundtripped: Config = toml::from_str(&serialized).unwrap();
3146 assert_eq!(
3147 roundtripped.settings.default_harness,
3148 config.settings.default_harness
3149 );
3150 }
3151
3152 #[test]
3153 fn settings_default_model_parses_and_roundtrips() {
3154 let config: Config = toml::from_str(
3155 r#"
3156[settings]
3157default_model = "gpt-5.4-mini"
3158"#,
3159 )
3160 .unwrap();
3161 assert_eq!(
3162 config.settings.default_model.as_deref(),
3163 Some("gpt-5.4-mini")
3164 );
3165
3166 let serialized = toml::to_string_pretty(&config).unwrap();
3167 let roundtripped: Config = toml::from_str(&serialized).unwrap();
3168 assert_eq!(
3169 roundtripped.settings.default_model,
3170 config.settings.default_model
3171 );
3172 }
3173
3174 #[test]
3175 fn settings_harness_order_parses_and_roundtrips() {
3176 let config: Config = toml::from_str(
3177 r#"
3178[settings]
3179harness_order = ["pi", "opencode", "codex", "claude"]
3180"#,
3181 )
3182 .unwrap();
3183 assert_eq!(
3184 config.settings.harness_order,
3185 Some(vec![
3186 "pi".to_string(),
3187 "opencode".to_string(),
3188 "codex".to_string(),
3189 "claude".to_string()
3190 ])
3191 );
3192
3193 let serialized = toml::to_string_pretty(&config).unwrap();
3194 let roundtripped: Config = toml::from_str(&serialized).unwrap();
3195 assert_eq!(
3196 roundtripped.settings.harness_order,
3197 config.settings.harness_order
3198 );
3199 }
3200
3201 #[test]
3202 fn settings_agent_emission_roundtrip_preserves_value() {
3203 let original = Config {
3204 settings: Settings {
3205 agent_emission: Some(AgentEmission::Always),
3206 ..Settings::default()
3207 },
3208 ..Config::default()
3209 };
3210 let serialized = toml::to_string_pretty(&original).unwrap();
3211 let roundtripped: Config = toml::from_str(&serialized).unwrap();
3212 assert_eq!(
3213 roundtripped.settings.agent_emission,
3214 original.settings.agent_emission
3215 );
3216 }
3217
3218 #[test]
3219 fn model_visibility_validate_allows_include_and_exclude() {
3220 let visibility = ModelVisibility {
3221 include: Some(vec!["opus*".into()]),
3222 exclude: Some(vec!["test*".into()]),
3223 };
3224 visibility.validate().unwrap();
3225 }
3226
3227 #[test]
3228 fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
3229 ModelVisibility {
3230 include: Some(vec!["opus*".into()]),
3231 exclude: None,
3232 }
3233 .validate()
3234 .unwrap();
3235 ModelVisibility {
3236 include: None,
3237 exclude: Some(vec!["test*".into()]),
3238 }
3239 .validate()
3240 .unwrap();
3241 ModelVisibility::default().validate().unwrap();
3242 }
3243
3244 #[test]
3245 fn model_visibility_is_empty_reports_state() {
3246 assert!(ModelVisibility::default().is_empty());
3247 assert!(
3248 !ModelVisibility {
3249 include: Some(vec!["opus*".into()]),
3250 exclude: None,
3251 }
3252 .is_empty()
3253 );
3254 assert!(
3255 !ModelVisibility {
3256 include: None,
3257 exclude: Some(vec!["test*".into()]),
3258 }
3259 .is_empty()
3260 );
3261 }
3262
3263 #[test]
3264 fn load_accepts_model_visibility_with_include_and_exclude() {
3265 let dir = TempDir::new().unwrap();
3266 std::fs::write(
3267 dir.path().join("mars.toml"),
3268 r#"
3269[settings.model_visibility]
3270include = ["opus*"]
3271exclude = ["test*"]
3272"#,
3273 )
3274 .unwrap();
3275
3276 let config = load(dir.path()).unwrap();
3277 assert_eq!(
3278 config.settings.model_visibility.include,
3279 Some(vec!["opus*".into()])
3280 );
3281 assert_eq!(
3282 config.settings.model_visibility.exclude,
3283 Some(vec!["test*".into()])
3284 );
3285 }
3286
3287 #[test]
3288 fn load_accepts_model_visibility_include_only() {
3289 let dir = TempDir::new().unwrap();
3290 std::fs::write(
3291 dir.path().join("mars.toml"),
3292 r#"
3293[settings.model_visibility]
3294include = ["opus*", "gpt-*"]
3295"#,
3296 )
3297 .unwrap();
3298
3299 let config = load(dir.path()).unwrap();
3300 assert_eq!(
3301 config.settings.model_visibility.include,
3302 Some(vec!["opus*".into(), "gpt-*".into()])
3303 );
3304 assert!(config.settings.model_visibility.exclude.is_none());
3305 }
3306
3307 #[test]
3308 fn load_accepts_model_visibility_exclude_only() {
3309 let dir = TempDir::new().unwrap();
3310 std::fs::write(
3311 dir.path().join("mars.toml"),
3312 r#"
3313[settings.model_visibility]
3314exclude = ["test-*", "deprecated-*"]
3315"#,
3316 )
3317 .unwrap();
3318
3319 let config = load(dir.path()).unwrap();
3320 assert_eq!(
3321 config.settings.model_visibility.exclude,
3322 Some(vec!["test-*".into(), "deprecated-*".into()])
3323 );
3324 assert!(config.settings.model_visibility.include.is_none());
3325 }
3326
3327 #[test]
3330 fn parse_local_dependencies() {
3331 let toml_str = r#"
3332[dependencies.base]
3333url = "https://github.com/org/base.git"
3334
3335[local-dependencies.prompter]
3336url = "https://github.com/org/prompter.git"
3337skills = ["prompt-helper"]
3338"#;
3339 let config: Config = toml::from_str(toml_str).unwrap();
3340 assert_eq!(config.dependencies.len(), 1);
3341 assert_eq!(config.local_dependencies.len(), 1);
3342 assert!(config.local_dependencies.contains_key("prompter"));
3343 assert_eq!(
3344 config.local_dependencies["prompter"].url.as_deref(),
3345 Some("https://github.com/org/prompter.git")
3346 );
3347 }
3348
3349 #[test]
3350 fn local_dependencies_merged_into_effective_config() {
3351 let toml_str = r#"
3352[dependencies.base]
3353url = "https://github.com/org/base.git"
3354
3355[local-dependencies.prompter]
3356url = "https://github.com/org/prompter.git"
3357"#;
3358 let config: Config = toml::from_str(toml_str).unwrap();
3359 let local = LocalConfig::default();
3360 let effective = merge(config, local).unwrap();
3361
3362 assert_eq!(effective.dependencies.len(), 2);
3364 assert!(effective.dependencies.contains_key("base"));
3365 assert!(effective.dependencies.contains_key("prompter"));
3366 }
3367
3368 #[test]
3369 fn local_dependencies_not_exported_to_manifest() {
3370 let dir = TempDir::new().unwrap();
3371 std::fs::write(
3372 dir.path().join("mars.toml"),
3373 r#"
3374[package]
3375name = "my-package"
3376version = "1.0.0"
3377
3378[dependencies.base]
3379url = "https://github.com/org/base.git"
3380
3381[local-dependencies.prompter]
3382url = "https://github.com/org/prompter.git"
3383"#,
3384 )
3385 .unwrap();
3386
3387 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
3388 assert!(diagnostics.is_empty());
3389 let manifest = manifest.unwrap();
3390
3391 assert_eq!(manifest.dependencies.len(), 1);
3393 assert!(manifest.dependencies.contains_key("base"));
3394 assert!(!manifest.dependencies.contains_key("prompter"));
3395 }
3396
3397 #[test]
3398 fn error_on_duplicate_name_across_sections() {
3399 let toml_str = r#"
3400[dependencies.base]
3401url = "https://github.com/org/base.git"
3402
3403[local-dependencies.base]
3404url = "https://github.com/org/base-local.git"
3405"#;
3406 let config: Config = toml::from_str(toml_str).unwrap();
3407 let local = LocalConfig::default();
3408 let result = merge(config, local);
3409 assert!(result.is_err());
3410 let err = result.unwrap_err().to_string();
3411 assert!(
3412 err.contains("base") && err.contains("both"),
3413 "should reject duplicate name: {err}"
3414 );
3415 }
3416
3417 #[test]
3418 fn local_dependencies_roundtrip() {
3419 let dir = TempDir::new().unwrap();
3420 let original = r#"
3421[dependencies.base]
3422url = "https://github.com/org/base.git"
3423
3424[local-dependencies.prompter]
3425url = "https://github.com/org/prompter.git"
3426skills = ["prompt-helper"]
3427"#;
3428 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
3429
3430 let config = load(dir.path()).unwrap();
3431 save(dir.path(), &config).unwrap();
3432 let reloaded = load(dir.path()).unwrap();
3433
3434 assert_eq!(reloaded.dependencies.len(), 1);
3435 assert_eq!(reloaded.local_dependencies.len(), 1);
3436 assert!(reloaded.local_dependencies.contains_key("prompter"));
3437 assert_eq!(
3438 reloaded.local_dependencies["prompter"]
3439 .filter
3440 .skills
3441 .as_deref(),
3442 Some(&["prompt-helper".into()][..])
3443 );
3444 }
3445
3446 #[test]
3447 fn path_with_backslashes_serializes_as_forward_slashes() {
3448 let mut deps = IndexMap::new();
3449 deps.insert(
3450 SourceName::from("test-src"),
3451 InstallDep {
3452 url: None,
3453 path: Some(PathBuf::from("C:\\Users\\dev\\src")),
3454 subpath: None,
3455 version: None,
3456 dialect: None,
3457 filter: FilterConfig::default(),
3458 },
3459 );
3460 let config = Config {
3461 dependencies: deps,
3462 ..Config::default()
3463 };
3464 let toml_str = toml::to_string_pretty(&config).unwrap();
3465 assert!(
3466 !toml_str.contains('\\'),
3467 "TOML output must not contain backslashes: {toml_str}"
3468 );
3469 assert!(
3470 toml_str.contains("C:/Users/dev/src"),
3471 "expected forward-slash path in TOML: {toml_str}"
3472 );
3473 let reparsed: Config = toml::from_str(&toml_str).unwrap();
3474 assert_eq!(
3475 reparsed.dependencies["test-src"].path.as_ref().unwrap(),
3476 &PathBuf::from("C:/Users/dev/src"),
3477 );
3478 }
3479
3480 #[test]
3481 fn override_path_serializes_forward_slashes() {
3482 let mut overrides = IndexMap::new();
3483 overrides.insert(
3484 SourceName::from("my-dep"),
3485 OverrideEntry {
3486 path: PathBuf::from("C:\\Users\\dev\\local-pkg"),
3487 },
3488 );
3489 let local = LocalConfig {
3490 overrides,
3491 ..LocalConfig::default()
3492 };
3493 let toml_str = toml::to_string_pretty(&local).unwrap();
3494 assert!(
3495 !toml_str.contains('\\'),
3496 "local config TOML must not contain backslashes: {toml_str}"
3497 );
3498 assert!(
3499 toml_str.contains("C:/Users/dev/local-pkg"),
3500 "expected forward-slash override path: {toml_str}"
3501 );
3502 }
3503}