1use std::collections::BTreeMap;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::config::InteractionLevel;
10use super::item::Capability;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct Project {
14 pub name: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub acronym: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub description: Option<String>,
19 #[serde(default = "default_language")]
20 pub language: String,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub forge: Option<String>,
23 #[serde(default, skip_serializing_if = "Docs::is_empty")]
24 pub docs: Docs,
25 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26 pub members: BTreeMap<String, Member>,
27 #[serde(default, skip_serializing_if = "CryptConfig::is_empty")]
32 pub crypt: CryptConfig,
33 pub created: DateTime<Utc>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
41pub struct CryptConfig {
42 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43 pub zones: BTreeMap<String, CryptZone>,
44}
45
46impl CryptConfig {
47 pub fn is_empty(&self) -> bool {
48 self.zones.is_empty()
49 }
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
58pub struct CryptZone {
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub paths: Vec<String>,
64 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75 pub delegations: BTreeMap<String, BTreeMap<String, String>>,
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct Docs {
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub architecture: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub vision: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub contributing: Option<String>,
89}
90
91impl Docs {
92 pub const DEFAULT_ARCHITECTURE: &'static str = "docs/dev/architecture/README.md";
93 pub const DEFAULT_VISION: &'static str = "docs/dev/vision/README.md";
94 pub const DEFAULT_CONTRIBUTING: &'static str = "CONTRIBUTING.md";
95
96 pub fn is_empty(&self) -> bool {
97 self.architecture.is_none() && self.vision.is_none() && self.contributing.is_none()
98 }
99
100 pub fn architecture_or_default(&self) -> &str {
102 self.architecture
103 .as_deref()
104 .unwrap_or(Self::DEFAULT_ARCHITECTURE)
105 }
106
107 pub fn vision_or_default(&self) -> &str {
109 self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
110 }
111
112 pub fn contributing_or_default(&self) -> &str {
114 self.contributing
115 .as_deref()
116 .unwrap_or(Self::DEFAULT_CONTRIBUTING)
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct Member {
122 pub capabilities: MemberCapabilities,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub verify_key: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub kdf_nonce: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub seed_wrap_passphrase: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub seed_wrap_recovery: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub enrollment_verifier: Option<String>,
140 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
141 pub ai_delegations: BTreeMap<String, AiDelegationEntry>,
142 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
148 pub crypt_wraps: BTreeMap<String, String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub attestation: Option<Attestation>,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct Attestation {
161 pub attester: String,
164 pub signed_fields: AttestationSignedFields,
168 pub signed_at: chrono::DateTime<chrono::Utc>,
170 pub signature: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct AttestationSignedFields {
184 pub email: String,
185 pub capabilities: MemberCapabilities,
186 #[serde(default, rename = "otp_hash", skip_serializing_if = "Option::is_none")]
187 pub enrollment_verifier: Option<String>,
188}
189
190impl AttestationSignedFields {
191 pub fn canonical_bytes(&self) -> Vec<u8> {
196 serde_json::to_vec(self).expect("AttestationSignedFields canonicalization")
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct AiDelegationEntry {
217 pub delegation_verifier: String,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub delegation_salt: Option<String>,
225 pub created: chrono::DateTime<chrono::Utc>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub rotated: Option<chrono::DateTime<chrono::Utc>>,
230}
231
232#[derive(Debug, Clone, PartialEq)]
233pub enum MemberCapabilities {
234 All,
235 Specific(BTreeMap<Capability, CapabilityConfig>),
236}
237
238#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
239pub struct CapabilityConfig {
240 #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
241 pub max_mode: Option<InteractionLevel>,
242 #[serde(
243 rename = "max-cost-per-job",
244 default,
245 skip_serializing_if = "Option::is_none"
246 )]
247 pub max_cost_per_job: Option<f64>,
248}
249
250#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
257pub struct ModeDefaults {
258 #[serde(default)]
260 pub default: InteractionLevel,
261 #[serde(flatten, default)]
263 pub capabilities: BTreeMap<Capability, InteractionLevel>,
264}
265
266#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
269pub struct AiDefaults {
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub capabilities: Vec<Capability>,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum ModeSource {
277 Default,
279 Project,
281 Personal,
283 Item,
285 ProjectMax,
287}
288
289impl std::fmt::Display for ModeSource {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 match self {
292 Self::Default => write!(f, "default"),
293 Self::Project => write!(f, "project"),
294 Self::Personal => write!(f, "personal"),
295 Self::Item => write!(f, "item"),
296 Self::ProjectMax => write!(f, "project max"),
297 }
298 }
299}
300
301pub fn resolve_mode(
310 capability: &Capability,
311 raw_defaults: &ModeDefaults,
312 effective_defaults: &ModeDefaults,
313 personal_mode: Option<InteractionLevel>,
314 member_cap_config: Option<&CapabilityConfig>,
315) -> (InteractionLevel, ModeSource) {
316 let mut mode = effective_defaults.default;
318 let mut source = if effective_defaults.default != raw_defaults.default {
319 ModeSource::Project
320 } else {
321 ModeSource::Default
322 };
323
324 if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
326 mode = cap_mode;
327 let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
328 source = if from_raw {
329 ModeSource::Default
330 } else {
331 ModeSource::Project
332 };
333 }
334
335 if let Some(personal) = personal_mode {
337 mode = personal;
338 source = ModeSource::Personal;
339 }
340
341 if let Some(cap_config) = member_cap_config {
343 if let Some(max) = cap_config.max_mode {
344 if mode < max {
345 mode = max;
346 source = ModeSource::ProjectMax;
347 }
348 }
349 }
350
351 (mode, source)
352}
353
354impl Serialize for MemberCapabilities {
356 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
357 match self {
358 MemberCapabilities::All => serializer.serialize_str("all"),
359 MemberCapabilities::Specific(map) => map.serialize(serializer),
360 }
361 }
362}
363
364impl<'de> Deserialize<'de> for MemberCapabilities {
365 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
366 let value = serde_yaml_ng::Value::deserialize(deserializer)?;
367 match &value {
368 serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
369 serde_yaml_ng::Value::Mapping(_) => {
370 let map: BTreeMap<Capability, CapabilityConfig> =
371 serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
372 Ok(MemberCapabilities::Specific(map))
373 }
374 _ => Err(serde::de::Error::custom(
375 "expected \"all\" or a map of capabilities",
376 )),
377 }
378 }
379}
380
381impl Member {
382 pub fn new(capabilities: MemberCapabilities) -> Self {
384 Self {
385 capabilities,
386 verify_key: None,
387 kdf_nonce: None,
388 seed_wrap_passphrase: None,
389 seed_wrap_recovery: None,
390 enrollment_verifier: None,
391 ai_delegations: BTreeMap::new(),
392 crypt_wraps: BTreeMap::new(),
393 attestation: None,
394 }
395 }
396
397 pub fn has_capability(&self, cap: &Capability) -> bool {
399 match &self.capabilities {
400 MemberCapabilities::All => true,
401 MemberCapabilities::Specific(map) => map.contains_key(cap),
402 }
403 }
404}
405
406pub fn is_ai_member(id: &str) -> bool {
408 id.starts_with("ai:")
409}
410
411fn default_language() -> String {
412 "en".to_string()
413}
414
415impl Project {
416 pub fn new(name: String, acronym: Option<String>) -> Self {
417 Self {
418 name,
419 acronym,
420 description: None,
421 language: default_language(),
422 forge: None,
423 docs: Docs::default(),
424 members: BTreeMap::new(),
425 crypt: CryptConfig::default(),
426 created: Utc::now(),
427 }
428 }
429}
430
431pub fn validate_acronym(value: &str) -> Result<String, String> {
438 let normalized = value.trim().to_uppercase();
439 if normalized.len() < 2 || normalized.len() > 8 {
440 return Err(format!(
441 "acronym must be 2-8 characters, got {} ('{}')",
442 normalized.len(),
443 normalized
444 ));
445 }
446 for (i, c) in normalized.chars().enumerate() {
447 if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
448 return Err(format!(
449 "acronym character '{c}' at position {i} is not A-Z or 0-9"
450 ));
451 }
452 }
453 Ok(normalized)
454}
455
456pub fn derive_acronym(name: &str) -> String {
460 let words: Vec<&str> = name.split_whitespace().collect();
461 if words.len() == 1 {
462 words[0]
463 .chars()
464 .filter(|c| c.is_alphanumeric())
465 .take(3)
466 .collect::<String>()
467 .to_uppercase()
468 } else {
469 words
470 .iter()
471 .filter_map(|w| w.chars().next())
472 .filter(|c| c.is_alphanumeric())
473 .take(4)
474 .collect::<String>()
475 .to_uppercase()
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn project_roundtrip() {
485 let project = Project::new("Test Project".into(), Some("TP".into()));
486 let yaml = serde_yaml_ng::to_string(&project).unwrap();
487 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
488 assert_eq!(project, parsed);
489 }
490
491 #[test]
496 fn ai_delegations_omitted_when_empty() {
497 let mut m = Member::new(MemberCapabilities::All);
498 assert!(m.ai_delegations.is_empty());
499 let yaml = serde_yaml_ng::to_string(&m).unwrap();
500 assert!(
501 !yaml.contains("ai_delegations"),
502 "empty ai_delegations should be skipped, got: {yaml}"
503 );
504 m.verify_key = Some("aa".repeat(32));
506 let yaml = serde_yaml_ng::to_string(&m).unwrap();
507 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
508 assert_eq!(m, parsed);
509 }
510
511 #[test]
512 fn ai_delegations_yaml_roundtrip() {
513 let mut m = Member::new(MemberCapabilities::All);
514 m.verify_key = Some("aa".repeat(32));
515 m.kdf_nonce = Some("bb".repeat(32));
516 m.ai_delegations.insert(
517 "ai:claude@joy".into(),
518 AiDelegationEntry {
519 delegation_verifier: "cc".repeat(32),
520 delegation_salt: None,
521 created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
522 .unwrap()
523 .with_timezone(&chrono::Utc),
524 rotated: None,
525 },
526 );
527 let yaml = serde_yaml_ng::to_string(&m).unwrap();
528 assert!(yaml.contains("ai_delegations:"));
529 assert!(yaml.contains("ai:claude@joy:"));
530 assert!(yaml.contains("delegation_verifier:"));
531 assert!(
532 !yaml.contains("delegation_salt:"),
533 "unset delegation_salt should be skipped (legacy entry)"
534 );
535 assert!(
536 !yaml.contains("rotated:"),
537 "unset rotated should be skipped"
538 );
539
540 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
541 assert_eq!(m, parsed);
542 }
543
544 #[test]
545 fn ai_delegations_with_rotated_roundtrips() {
546 let mut m = Member::new(MemberCapabilities::All);
547 let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
548 .unwrap()
549 .with_timezone(&chrono::Utc);
550 let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
551 .unwrap()
552 .with_timezone(&chrono::Utc);
553 m.ai_delegations.insert(
554 "ai:claude@joy".into(),
555 AiDelegationEntry {
556 delegation_verifier: "dd".repeat(32),
557 delegation_salt: None,
558 created,
559 rotated: Some(rotated),
560 },
561 );
562 let yaml = serde_yaml_ng::to_string(&m).unwrap();
563 assert!(yaml.contains("rotated:"));
564 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
565 assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
566 assert_eq!(parsed, m);
567 }
568
569 #[test]
574 fn attestation_omitted_when_none() {
575 let m = Member::new(MemberCapabilities::All);
576 let yaml = serde_yaml_ng::to_string(&m).unwrap();
577 assert!(!yaml.contains("attestation:"));
578 }
579
580 #[test]
581 fn attestation_yaml_roundtrips() {
582 let mut m = Member::new(MemberCapabilities::All);
583 m.attestation = Some(Attestation {
584 attester: "horst@example.com".into(),
585 signed_fields: AttestationSignedFields {
586 email: "alice@example.com".into(),
587 capabilities: MemberCapabilities::All,
588 enrollment_verifier: Some("ff".repeat(32)),
589 },
590 signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
591 .unwrap()
592 .with_timezone(&chrono::Utc),
593 signature: "aa".repeat(32),
594 });
595 let yaml = serde_yaml_ng::to_string(&m).unwrap();
596 assert!(yaml.contains("attestation:"));
597 assert!(yaml.contains("attester: horst@example.com"));
598 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
599 assert_eq!(parsed, m);
600 }
601
602 #[test]
603 fn attestation_signed_fields_canonical_is_deterministic() {
604 let a = AttestationSignedFields {
605 email: "alice@example.com".into(),
606 capabilities: MemberCapabilities::All,
607 enrollment_verifier: Some("abc".into()),
608 };
609 let b = a.clone();
610 assert_eq!(a.canonical_bytes(), b.canonical_bytes());
611 }
612
613 #[test]
614 fn attestation_signed_fields_differ_on_capability_change() {
615 let a = AttestationSignedFields {
616 email: "alice@example.com".into(),
617 capabilities: MemberCapabilities::All,
618 enrollment_verifier: None,
619 };
620 let mut caps = BTreeMap::new();
621 caps.insert(Capability::Implement, CapabilityConfig::default());
622 let b = AttestationSignedFields {
623 email: "alice@example.com".into(),
624 capabilities: MemberCapabilities::Specific(caps),
625 enrollment_verifier: None,
626 };
627 assert_ne!(a.canonical_bytes(), b.canonical_bytes());
628 }
629
630 #[test]
631 fn unknown_fields_from_legacy_yaml_are_ignored() {
632 let yaml = r#"
636capabilities: all
637public_key: aa
638salt: bb
639ai_tokens:
640 ai:claude@joy:
641 token_key: oldkey
642 created: "2026-03-28T22:00:00Z"
643ai_delegations:
644 ai:claude@joy:
645 delegation_verifier: newkey
646 created: "2026-04-15T10:00:00Z"
647"#;
648 let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
649 assert_eq!(
650 parsed.ai_delegations["ai:claude@joy"].delegation_verifier,
651 "newkey"
652 );
653 }
654
655 #[test]
660 fn docs_defaults_when_unset() {
661 let docs = Docs::default();
662 assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
663 assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
664 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
665 }
666
667 #[test]
668 fn docs_returns_configured_value() {
669 let docs = Docs {
670 architecture: Some("ARCHITECTURE.md".into()),
671 vision: Some("docs/product/vision.md".into()),
672 contributing: None,
673 };
674 assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
675 assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
676 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
677 }
678
679 #[test]
680 fn docs_omitted_from_yaml_when_empty() {
681 let project = Project::new("X".into(), None);
682 let yaml = serde_yaml_ng::to_string(&project).unwrap();
683 assert!(
684 !yaml.contains("docs:"),
685 "empty docs should be skipped, got: {yaml}"
686 );
687 }
688
689 #[test]
690 fn docs_present_in_yaml_when_set() {
691 let mut project = Project::new("X".into(), None);
692 project.docs.architecture = Some("ARCHITECTURE.md".into());
693 let yaml = serde_yaml_ng::to_string(&project).unwrap();
694 assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
695 assert!(yaml.contains("architecture: ARCHITECTURE.md"));
696 assert!(!yaml.contains("vision:"), "unset fields should be skipped");
697 }
698
699 #[test]
700 fn docs_yaml_roundtrip_with_overrides() {
701 let yaml = r#"
702name: Existing
703language: en
704docs:
705 architecture: ARCHITECTURE.md
706 contributing: docs/CONTRIBUTING.md
707created: 2026-01-01T00:00:00Z
708"#;
709 let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
710 assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
711 assert_eq!(parsed.docs.vision, None);
712 assert_eq!(
713 parsed.docs.contributing.as_deref(),
714 Some("docs/CONTRIBUTING.md")
715 );
716 assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
717 }
718
719 #[test]
720 fn derive_acronym_multi_word() {
721 assert_eq!(derive_acronym("My Cool Project"), "MCP");
722 }
723
724 #[test]
725 fn derive_acronym_single_word() {
726 assert_eq!(derive_acronym("Joy"), "JOY");
727 }
728
729 #[test]
730 fn derive_acronym_long_name() {
731 assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
732 }
733
734 #[test]
735 fn derive_acronym_single_long_word() {
736 assert_eq!(derive_acronym("Platform"), "PLA");
737 }
738
739 #[test]
744 fn validate_acronym_accepts_real_project_acronyms() {
745 for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
746 assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
747 }
748 }
749
750 #[test]
751 fn validate_acronym_accepts_alphanumeric() {
752 assert_eq!(validate_acronym("V2").unwrap(), "V2");
753 assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
754 }
755
756 #[test]
757 fn validate_acronym_normalizes_case_and_whitespace() {
758 assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
759 assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
760 assert_eq!(validate_acronym(" jyn ").unwrap(), "JYN");
761 }
762
763 #[test]
764 fn validate_acronym_rejects_too_short() {
765 assert!(validate_acronym("").is_err());
766 assert!(validate_acronym("J").is_err());
767 assert!(validate_acronym(" J ").is_err());
768 }
769
770 #[test]
771 fn validate_acronym_rejects_too_long() {
772 assert!(validate_acronym("ABCDEFGHI").is_err());
773 }
774
775 #[test]
776 fn validate_acronym_rejects_non_alnum() {
777 assert!(validate_acronym("JY-N").is_err());
778 assert!(validate_acronym("JY N").is_err());
779 assert!(validate_acronym("JY_N").is_err());
780 assert!(validate_acronym("JY.N").is_err());
781 }
782
783 #[test]
784 fn validate_acronym_rejects_non_ascii() {
785 assert!(validate_acronym("AEBC").is_ok());
786 assert!(validate_acronym("ABC").is_ok());
787 assert!(validate_acronym("\u{00c4}BC").is_err());
788 }
789
790 #[test]
795 fn mode_defaults_flat_yaml_roundtrip() {
796 let yaml = r#"
797default: interactive
798implement: collaborative
799review: pairing
800"#;
801 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
802 assert_eq!(parsed.default, InteractionLevel::Interactive);
803 assert_eq!(
804 parsed.capabilities[&Capability::Implement],
805 InteractionLevel::Collaborative
806 );
807 assert_eq!(
808 parsed.capabilities[&Capability::Review],
809 InteractionLevel::Pairing
810 );
811 }
812
813 #[test]
814 fn mode_defaults_empty_yaml() {
815 let yaml = "{}";
816 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
817 assert_eq!(parsed.default, InteractionLevel::Collaborative);
818 assert!(parsed.capabilities.is_empty());
819 }
820
821 #[test]
822 fn mode_defaults_only_default() {
823 let yaml = "default: pairing";
824 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
825 assert_eq!(parsed.default, InteractionLevel::Pairing);
826 assert!(parsed.capabilities.is_empty());
827 }
828
829 #[test]
830 fn ai_defaults_yaml_roundtrip() {
831 let yaml = r#"
832capabilities:
833 - implement
834 - review
835"#;
836 let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
837 assert_eq!(parsed.capabilities.len(), 2);
838 assert_eq!(parsed.capabilities[0], Capability::Implement);
839 }
840
841 fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
846 ModeDefaults {
847 default: mode,
848 ..Default::default()
849 }
850 }
851
852 fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
853 let mut d = ModeDefaults::default();
854 d.capabilities.insert(cap, mode);
855 d
856 }
857
858 #[test]
859 fn resolve_mode_uses_global_default() {
860 let raw = defaults_with_mode(InteractionLevel::Collaborative);
861 let effective = raw.clone();
862 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
863 assert_eq!(mode, InteractionLevel::Collaborative);
864 assert_eq!(source, ModeSource::Default);
865 }
866
867 #[test]
868 fn resolve_mode_uses_per_capability_default() {
869 let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
870 let effective = raw.clone();
871 let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
872 assert_eq!(mode, InteractionLevel::Interactive);
873 assert_eq!(source, ModeSource::Default);
874 }
875
876 #[test]
877 fn resolve_mode_project_override_detected() {
878 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
879 let effective =
880 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
881 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
882 assert_eq!(mode, InteractionLevel::Interactive);
883 assert_eq!(source, ModeSource::Project);
884 }
885
886 #[test]
887 fn resolve_mode_personal_overrides_default() {
888 let raw = defaults_with_mode(InteractionLevel::Collaborative);
889 let effective = raw.clone();
890 let (mode, source) = resolve_mode(
891 &Capability::Implement,
892 &raw,
893 &effective,
894 Some(InteractionLevel::Pairing),
895 None,
896 );
897 assert_eq!(mode, InteractionLevel::Pairing);
898 assert_eq!(source, ModeSource::Personal);
899 }
900
901 #[test]
902 fn resolve_mode_max_mode_clamps_upward() {
903 let raw = defaults_with_mode(InteractionLevel::Autonomous);
904 let effective = raw.clone();
905 let cap_config = CapabilityConfig {
906 max_mode: Some(InteractionLevel::Supervised),
907 ..Default::default()
908 };
909 let (mode, source) = resolve_mode(
910 &Capability::Implement,
911 &raw,
912 &effective,
913 None,
914 Some(&cap_config),
915 );
916 assert_eq!(mode, InteractionLevel::Supervised);
917 assert_eq!(source, ModeSource::ProjectMax);
918 }
919
920 #[test]
921 fn resolve_mode_max_mode_does_not_lower() {
922 let raw = defaults_with_mode(InteractionLevel::Pairing);
923 let effective = raw.clone();
924 let cap_config = CapabilityConfig {
925 max_mode: Some(InteractionLevel::Supervised),
926 ..Default::default()
927 };
928 let (mode, source) = resolve_mode(
929 &Capability::Implement,
930 &raw,
931 &effective,
932 None,
933 Some(&cap_config),
934 );
935 assert_eq!(mode, InteractionLevel::Pairing);
937 assert_eq!(source, ModeSource::Default);
938 }
939
940 #[test]
941 fn resolve_mode_personal_clamped_by_max() {
942 let raw = defaults_with_mode(InteractionLevel::Collaborative);
943 let effective = raw.clone();
944 let cap_config = CapabilityConfig {
945 max_mode: Some(InteractionLevel::Interactive),
946 ..Default::default()
947 };
948 let (mode, source) = resolve_mode(
949 &Capability::Implement,
950 &raw,
951 &effective,
952 Some(InteractionLevel::Autonomous),
953 Some(&cap_config),
954 );
955 assert_eq!(mode, InteractionLevel::Interactive);
957 assert_eq!(source, ModeSource::ProjectMax);
958 }
959
960 #[test]
965 fn item_mode_field_roundtrip() {
966 use crate::model::item::{Item, ItemType, Priority};
967
968 let mut item = Item::new(
969 "TST-0001".into(),
970 "Test".into(),
971 ItemType::Task,
972 Priority::Medium,
973 vec![],
974 );
975 item.mode = Some(InteractionLevel::Pairing);
976
977 let yaml = serde_yaml_ng::to_string(&item).unwrap();
978 assert!(yaml.contains("mode: pairing"), "mode field not serialized");
979
980 let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
981 assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
982 }
983
984 #[test]
985 fn item_mode_field_absent_when_none() {
986 use crate::model::item::{Item, ItemType, Priority};
987
988 let item = Item::new(
989 "TST-0002".into(),
990 "Test".into(),
991 ItemType::Task,
992 Priority::Medium,
993 vec![],
994 );
995 assert_eq!(item.mode, None);
996
997 let yaml = serde_yaml_ng::to_string(&item).unwrap();
998 assert!(
999 !yaml.contains("mode:"),
1000 "mode field should not appear when None"
1001 );
1002 }
1003
1004 #[test]
1005 fn item_mode_deserialized_from_existing_yaml() {
1006 let yaml = r#"
1007id: TST-0003
1008title: Test
1009type: task
1010status: new
1011priority: medium
1012mode: interactive
1013created: "2026-01-01T00:00:00+00:00"
1014updated: "2026-01-01T00:00:00+00:00"
1015"#;
1016 let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
1017 assert_eq!(item.mode, Some(InteractionLevel::Interactive));
1018 }
1019
1020 #[test]
1025 fn resolve_mode_full_scenario() {
1026 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1028 let effective =
1030 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1031 let personal = Some(InteractionLevel::Autonomous);
1033 let cap_config = CapabilityConfig {
1035 max_mode: Some(InteractionLevel::Supervised),
1036 ..Default::default()
1037 };
1038
1039 let (mode, source) = resolve_mode(
1040 &Capability::Implement,
1041 &raw,
1042 &effective,
1043 personal,
1044 Some(&cap_config),
1045 );
1046
1047 assert_eq!(mode, InteractionLevel::Supervised);
1049 assert_eq!(source, ModeSource::ProjectMax);
1050 }
1051
1052 #[test]
1053 fn resolve_mode_all_layers_no_clamping() {
1054 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1056 let effective =
1058 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1059 let personal = Some(InteractionLevel::Pairing);
1061 let cap_config = CapabilityConfig::default();
1063
1064 let (mode, source) = resolve_mode(
1065 &Capability::Implement,
1066 &raw,
1067 &effective,
1068 personal,
1069 Some(&cap_config),
1070 );
1071
1072 assert_eq!(mode, InteractionLevel::Pairing);
1074 assert_eq!(source, ModeSource::Personal);
1075 }
1076}