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
411pub fn describe_value(key: &str, _value: &serde_json::Value) -> Option<String> {
416 let text = match key {
417 "name" => "human-readable project name",
418 "acronym" => "short prefix used in item IDs",
419 "description" => "one-paragraph project description",
420 "language" => "project language for written artifacts (titles, comments, commits)",
421 "created" => "ISO timestamp when the project was initialized",
422 "docs.architecture" => "path to the technical architecture document",
423 "docs.vision" => "path to the product-vision document",
424 "docs.contributing" => "path to the contributing guide",
425 _ => return None,
426 };
427 Some(text.to_string())
428}
429
430fn default_language() -> String {
431 "en".to_string()
432}
433
434impl Project {
435 pub fn new(name: String, acronym: Option<String>) -> Self {
436 Self {
437 name,
438 acronym,
439 description: None,
440 language: default_language(),
441 forge: None,
442 docs: Docs::default(),
443 members: BTreeMap::new(),
444 crypt: CryptConfig::default(),
445 created: Utc::now(),
446 }
447 }
448}
449
450pub fn validate_acronym(value: &str) -> Result<String, String> {
457 let normalized = value.trim().to_uppercase();
458 if normalized.len() < 2 || normalized.len() > 8 {
459 return Err(format!(
460 "acronym must be 2-8 characters, got {} ('{}')",
461 normalized.len(),
462 normalized
463 ));
464 }
465 for (i, c) in normalized.chars().enumerate() {
466 if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
467 return Err(format!(
468 "acronym character '{c}' at position {i} is not A-Z or 0-9"
469 ));
470 }
471 }
472 Ok(normalized)
473}
474
475pub fn derive_acronym(name: &str) -> String {
479 let words: Vec<&str> = name.split_whitespace().collect();
480 if words.len() == 1 {
481 words[0]
482 .chars()
483 .filter(|c| c.is_alphanumeric())
484 .take(3)
485 .collect::<String>()
486 .to_uppercase()
487 } else {
488 words
489 .iter()
490 .filter_map(|w| w.chars().next())
491 .filter(|c| c.is_alphanumeric())
492 .take(4)
493 .collect::<String>()
494 .to_uppercase()
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn project_roundtrip() {
504 let project = Project::new("Test Project".into(), Some("TP".into()));
505 let yaml = serde_yaml_ng::to_string(&project).unwrap();
506 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
507 assert_eq!(project, parsed);
508 }
509
510 #[test]
511 fn describe_value_covers_documented_keys() {
512 let dummy = serde_json::Value::Null;
513 for key in &[
514 "name",
515 "acronym",
516 "description",
517 "language",
518 "created",
519 "docs.architecture",
520 "docs.vision",
521 "docs.contributing",
522 ] {
523 assert!(
524 describe_value(key, &dummy).is_some(),
525 "missing description for project key {key}"
526 );
527 }
528 assert!(describe_value("unknown", &dummy).is_none());
529 }
530
531 #[test]
536 fn ai_delegations_omitted_when_empty() {
537 let mut m = Member::new(MemberCapabilities::All);
538 assert!(m.ai_delegations.is_empty());
539 let yaml = serde_yaml_ng::to_string(&m).unwrap();
540 assert!(
541 !yaml.contains("ai_delegations"),
542 "empty ai_delegations should be skipped, got: {yaml}"
543 );
544 m.verify_key = Some("aa".repeat(32));
546 let yaml = serde_yaml_ng::to_string(&m).unwrap();
547 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
548 assert_eq!(m, parsed);
549 }
550
551 #[test]
552 fn ai_delegations_yaml_roundtrip() {
553 let mut m = Member::new(MemberCapabilities::All);
554 m.verify_key = Some("aa".repeat(32));
555 m.kdf_nonce = Some("bb".repeat(32));
556 m.ai_delegations.insert(
557 "ai:claude@joy".into(),
558 AiDelegationEntry {
559 delegation_verifier: "cc".repeat(32),
560 delegation_salt: None,
561 created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
562 .unwrap()
563 .with_timezone(&chrono::Utc),
564 rotated: None,
565 },
566 );
567 let yaml = serde_yaml_ng::to_string(&m).unwrap();
568 assert!(yaml.contains("ai_delegations:"));
569 assert!(yaml.contains("ai:claude@joy:"));
570 assert!(yaml.contains("delegation_verifier:"));
571 assert!(
572 !yaml.contains("delegation_salt:"),
573 "unset delegation_salt should be skipped (legacy entry)"
574 );
575 assert!(
576 !yaml.contains("rotated:"),
577 "unset rotated should be skipped"
578 );
579
580 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
581 assert_eq!(m, parsed);
582 }
583
584 #[test]
585 fn ai_delegations_with_rotated_roundtrips() {
586 let mut m = Member::new(MemberCapabilities::All);
587 let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
588 .unwrap()
589 .with_timezone(&chrono::Utc);
590 let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
591 .unwrap()
592 .with_timezone(&chrono::Utc);
593 m.ai_delegations.insert(
594 "ai:claude@joy".into(),
595 AiDelegationEntry {
596 delegation_verifier: "dd".repeat(32),
597 delegation_salt: None,
598 created,
599 rotated: Some(rotated),
600 },
601 );
602 let yaml = serde_yaml_ng::to_string(&m).unwrap();
603 assert!(yaml.contains("rotated:"));
604 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
605 assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
606 assert_eq!(parsed, m);
607 }
608
609 #[test]
614 fn attestation_omitted_when_none() {
615 let m = Member::new(MemberCapabilities::All);
616 let yaml = serde_yaml_ng::to_string(&m).unwrap();
617 assert!(!yaml.contains("attestation:"));
618 }
619
620 #[test]
621 fn attestation_yaml_roundtrips() {
622 let mut m = Member::new(MemberCapabilities::All);
623 m.attestation = Some(Attestation {
624 attester: "horst@example.com".into(),
625 signed_fields: AttestationSignedFields {
626 email: "alice@example.com".into(),
627 capabilities: MemberCapabilities::All,
628 enrollment_verifier: Some("ff".repeat(32)),
629 },
630 signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
631 .unwrap()
632 .with_timezone(&chrono::Utc),
633 signature: "aa".repeat(32),
634 });
635 let yaml = serde_yaml_ng::to_string(&m).unwrap();
636 assert!(yaml.contains("attestation:"));
637 assert!(yaml.contains("attester: horst@example.com"));
638 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
639 assert_eq!(parsed, m);
640 }
641
642 #[test]
643 fn attestation_signed_fields_canonical_is_deterministic() {
644 let a = AttestationSignedFields {
645 email: "alice@example.com".into(),
646 capabilities: MemberCapabilities::All,
647 enrollment_verifier: Some("abc".into()),
648 };
649 let b = a.clone();
650 assert_eq!(a.canonical_bytes(), b.canonical_bytes());
651 }
652
653 #[test]
654 fn attestation_signed_fields_differ_on_capability_change() {
655 let a = AttestationSignedFields {
656 email: "alice@example.com".into(),
657 capabilities: MemberCapabilities::All,
658 enrollment_verifier: None,
659 };
660 let mut caps = BTreeMap::new();
661 caps.insert(Capability::Implement, CapabilityConfig::default());
662 let b = AttestationSignedFields {
663 email: "alice@example.com".into(),
664 capabilities: MemberCapabilities::Specific(caps),
665 enrollment_verifier: None,
666 };
667 assert_ne!(a.canonical_bytes(), b.canonical_bytes());
668 }
669
670 #[test]
671 fn unknown_fields_from_legacy_yaml_are_ignored() {
672 let yaml = r#"
676capabilities: all
677public_key: aa
678salt: bb
679ai_tokens:
680 ai:claude@joy:
681 token_key: oldkey
682 created: "2026-03-28T22:00:00Z"
683ai_delegations:
684 ai:claude@joy:
685 delegation_verifier: newkey
686 created: "2026-04-15T10:00:00Z"
687"#;
688 let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
689 assert_eq!(
690 parsed.ai_delegations["ai:claude@joy"].delegation_verifier,
691 "newkey"
692 );
693 }
694
695 #[test]
700 fn docs_defaults_when_unset() {
701 let docs = Docs::default();
702 assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
703 assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
704 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
705 }
706
707 #[test]
708 fn docs_returns_configured_value() {
709 let docs = Docs {
710 architecture: Some("ARCHITECTURE.md".into()),
711 vision: Some("docs/product/vision.md".into()),
712 contributing: None,
713 };
714 assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
715 assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
716 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
717 }
718
719 #[test]
720 fn docs_omitted_from_yaml_when_empty() {
721 let project = Project::new("X".into(), None);
722 let yaml = serde_yaml_ng::to_string(&project).unwrap();
723 assert!(
724 !yaml.contains("docs:"),
725 "empty docs should be skipped, got: {yaml}"
726 );
727 }
728
729 #[test]
730 fn docs_present_in_yaml_when_set() {
731 let mut project = Project::new("X".into(), None);
732 project.docs.architecture = Some("ARCHITECTURE.md".into());
733 let yaml = serde_yaml_ng::to_string(&project).unwrap();
734 assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
735 assert!(yaml.contains("architecture: ARCHITECTURE.md"));
736 assert!(!yaml.contains("vision:"), "unset fields should be skipped");
737 }
738
739 #[test]
740 fn docs_yaml_roundtrip_with_overrides() {
741 let yaml = r#"
742name: Existing
743language: en
744docs:
745 architecture: ARCHITECTURE.md
746 contributing: docs/CONTRIBUTING.md
747created: 2026-01-01T00:00:00Z
748"#;
749 let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
750 assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
751 assert_eq!(parsed.docs.vision, None);
752 assert_eq!(
753 parsed.docs.contributing.as_deref(),
754 Some("docs/CONTRIBUTING.md")
755 );
756 assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
757 }
758
759 #[test]
760 fn derive_acronym_multi_word() {
761 assert_eq!(derive_acronym("My Cool Project"), "MCP");
762 }
763
764 #[test]
765 fn derive_acronym_single_word() {
766 assert_eq!(derive_acronym("Joy"), "JOY");
767 }
768
769 #[test]
770 fn derive_acronym_long_name() {
771 assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
772 }
773
774 #[test]
775 fn derive_acronym_single_long_word() {
776 assert_eq!(derive_acronym("Platform"), "PLA");
777 }
778
779 #[test]
784 fn validate_acronym_accepts_real_project_acronyms() {
785 for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
786 assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
787 }
788 }
789
790 #[test]
791 fn validate_acronym_accepts_alphanumeric() {
792 assert_eq!(validate_acronym("V2").unwrap(), "V2");
793 assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
794 }
795
796 #[test]
797 fn validate_acronym_normalizes_case_and_whitespace() {
798 assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
799 assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
800 assert_eq!(validate_acronym(" jyn ").unwrap(), "JYN");
801 }
802
803 #[test]
804 fn validate_acronym_rejects_too_short() {
805 assert!(validate_acronym("").is_err());
806 assert!(validate_acronym("J").is_err());
807 assert!(validate_acronym(" J ").is_err());
808 }
809
810 #[test]
811 fn validate_acronym_rejects_too_long() {
812 assert!(validate_acronym("ABCDEFGHI").is_err());
813 }
814
815 #[test]
816 fn validate_acronym_rejects_non_alnum() {
817 assert!(validate_acronym("JY-N").is_err());
818 assert!(validate_acronym("JY N").is_err());
819 assert!(validate_acronym("JY_N").is_err());
820 assert!(validate_acronym("JY.N").is_err());
821 }
822
823 #[test]
824 fn validate_acronym_rejects_non_ascii() {
825 assert!(validate_acronym("AEBC").is_ok());
826 assert!(validate_acronym("ABC").is_ok());
827 assert!(validate_acronym("\u{00c4}BC").is_err());
828 }
829
830 #[test]
835 fn mode_defaults_flat_yaml_roundtrip() {
836 let yaml = r#"
837default: interactive
838implement: collaborative
839review: pairing
840"#;
841 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
842 assert_eq!(parsed.default, InteractionLevel::Interactive);
843 assert_eq!(
844 parsed.capabilities[&Capability::Implement],
845 InteractionLevel::Collaborative
846 );
847 assert_eq!(
848 parsed.capabilities[&Capability::Review],
849 InteractionLevel::Pairing
850 );
851 }
852
853 #[test]
854 fn mode_defaults_empty_yaml() {
855 let yaml = "{}";
856 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
857 assert_eq!(parsed.default, InteractionLevel::Collaborative);
858 assert!(parsed.capabilities.is_empty());
859 }
860
861 #[test]
862 fn mode_defaults_only_default() {
863 let yaml = "default: pairing";
864 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
865 assert_eq!(parsed.default, InteractionLevel::Pairing);
866 assert!(parsed.capabilities.is_empty());
867 }
868
869 #[test]
870 fn ai_defaults_yaml_roundtrip() {
871 let yaml = r#"
872capabilities:
873 - implement
874 - review
875"#;
876 let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
877 assert_eq!(parsed.capabilities.len(), 2);
878 assert_eq!(parsed.capabilities[0], Capability::Implement);
879 }
880
881 fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
886 ModeDefaults {
887 default: mode,
888 ..Default::default()
889 }
890 }
891
892 fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
893 let mut d = ModeDefaults::default();
894 d.capabilities.insert(cap, mode);
895 d
896 }
897
898 #[test]
899 fn resolve_mode_uses_global_default() {
900 let raw = defaults_with_mode(InteractionLevel::Collaborative);
901 let effective = raw.clone();
902 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
903 assert_eq!(mode, InteractionLevel::Collaborative);
904 assert_eq!(source, ModeSource::Default);
905 }
906
907 #[test]
908 fn resolve_mode_uses_per_capability_default() {
909 let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
910 let effective = raw.clone();
911 let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
912 assert_eq!(mode, InteractionLevel::Interactive);
913 assert_eq!(source, ModeSource::Default);
914 }
915
916 #[test]
917 fn resolve_mode_project_override_detected() {
918 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
919 let effective =
920 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
921 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
922 assert_eq!(mode, InteractionLevel::Interactive);
923 assert_eq!(source, ModeSource::Project);
924 }
925
926 #[test]
927 fn resolve_mode_personal_overrides_default() {
928 let raw = defaults_with_mode(InteractionLevel::Collaborative);
929 let effective = raw.clone();
930 let (mode, source) = resolve_mode(
931 &Capability::Implement,
932 &raw,
933 &effective,
934 Some(InteractionLevel::Pairing),
935 None,
936 );
937 assert_eq!(mode, InteractionLevel::Pairing);
938 assert_eq!(source, ModeSource::Personal);
939 }
940
941 #[test]
942 fn resolve_mode_max_mode_clamps_upward() {
943 let raw = defaults_with_mode(InteractionLevel::Autonomous);
944 let effective = raw.clone();
945 let cap_config = CapabilityConfig {
946 max_mode: Some(InteractionLevel::Supervised),
947 ..Default::default()
948 };
949 let (mode, source) = resolve_mode(
950 &Capability::Implement,
951 &raw,
952 &effective,
953 None,
954 Some(&cap_config),
955 );
956 assert_eq!(mode, InteractionLevel::Supervised);
957 assert_eq!(source, ModeSource::ProjectMax);
958 }
959
960 #[test]
961 fn resolve_mode_max_mode_does_not_lower() {
962 let raw = defaults_with_mode(InteractionLevel::Pairing);
963 let effective = raw.clone();
964 let cap_config = CapabilityConfig {
965 max_mode: Some(InteractionLevel::Supervised),
966 ..Default::default()
967 };
968 let (mode, source) = resolve_mode(
969 &Capability::Implement,
970 &raw,
971 &effective,
972 None,
973 Some(&cap_config),
974 );
975 assert_eq!(mode, InteractionLevel::Pairing);
977 assert_eq!(source, ModeSource::Default);
978 }
979
980 #[test]
981 fn resolve_mode_personal_clamped_by_max() {
982 let raw = defaults_with_mode(InteractionLevel::Collaborative);
983 let effective = raw.clone();
984 let cap_config = CapabilityConfig {
985 max_mode: Some(InteractionLevel::Interactive),
986 ..Default::default()
987 };
988 let (mode, source) = resolve_mode(
989 &Capability::Implement,
990 &raw,
991 &effective,
992 Some(InteractionLevel::Autonomous),
993 Some(&cap_config),
994 );
995 assert_eq!(mode, InteractionLevel::Interactive);
997 assert_eq!(source, ModeSource::ProjectMax);
998 }
999
1000 #[test]
1005 fn item_mode_field_roundtrip() {
1006 use crate::model::item::{Item, ItemType, Priority};
1007
1008 let mut item = Item::new(
1009 "TST-0001".into(),
1010 "Test".into(),
1011 ItemType::Task,
1012 Priority::Medium,
1013 vec![],
1014 );
1015 item.mode = Some(InteractionLevel::Pairing);
1016
1017 let yaml = serde_yaml_ng::to_string(&item).unwrap();
1018 assert!(yaml.contains("mode: pairing"), "mode field not serialized");
1019
1020 let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
1021 assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
1022 }
1023
1024 #[test]
1025 fn item_mode_field_absent_when_none() {
1026 use crate::model::item::{Item, ItemType, Priority};
1027
1028 let item = Item::new(
1029 "TST-0002".into(),
1030 "Test".into(),
1031 ItemType::Task,
1032 Priority::Medium,
1033 vec![],
1034 );
1035 assert_eq!(item.mode, None);
1036
1037 let yaml = serde_yaml_ng::to_string(&item).unwrap();
1038 assert!(
1039 !yaml.contains("mode:"),
1040 "mode field should not appear when None"
1041 );
1042 }
1043
1044 #[test]
1045 fn item_mode_deserialized_from_existing_yaml() {
1046 let yaml = r#"
1047id: TST-0003
1048title: Test
1049type: task
1050status: new
1051priority: medium
1052mode: interactive
1053created: "2026-01-01T00:00:00+00:00"
1054updated: "2026-01-01T00:00:00+00:00"
1055"#;
1056 let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
1057 assert_eq!(item.mode, Some(InteractionLevel::Interactive));
1058 }
1059
1060 #[test]
1065 fn resolve_mode_full_scenario() {
1066 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1068 let effective =
1070 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1071 let personal = Some(InteractionLevel::Autonomous);
1073 let cap_config = CapabilityConfig {
1075 max_mode: Some(InteractionLevel::Supervised),
1076 ..Default::default()
1077 };
1078
1079 let (mode, source) = resolve_mode(
1080 &Capability::Implement,
1081 &raw,
1082 &effective,
1083 personal,
1084 Some(&cap_config),
1085 );
1086
1087 assert_eq!(mode, InteractionLevel::Supervised);
1089 assert_eq!(source, ModeSource::ProjectMax);
1090 }
1091
1092 #[test]
1093 fn resolve_mode_all_layers_no_clamping() {
1094 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1096 let effective =
1098 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1099 let personal = Some(InteractionLevel::Pairing);
1101 let cap_config = CapabilityConfig::default();
1103
1104 let (mode, source) = resolve_mode(
1105 &Capability::Implement,
1106 &raw,
1107 &effective,
1108 personal,
1109 Some(&cap_config),
1110 );
1111
1112 assert_eq!(mode, InteractionLevel::Pairing);
1114 assert_eq!(source, ModeSource::Personal);
1115 }
1116}