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 pub created: DateTime<Utc>,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
34pub struct Docs {
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub architecture: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub vision: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub contributing: Option<String>,
41}
42
43impl Docs {
44 pub const DEFAULT_ARCHITECTURE: &'static str = "docs/dev/architecture/README.md";
45 pub const DEFAULT_VISION: &'static str = "docs/dev/vision/README.md";
46 pub const DEFAULT_CONTRIBUTING: &'static str = "CONTRIBUTING.md";
47
48 pub fn is_empty(&self) -> bool {
49 self.architecture.is_none() && self.vision.is_none() && self.contributing.is_none()
50 }
51
52 pub fn architecture_or_default(&self) -> &str {
54 self.architecture
55 .as_deref()
56 .unwrap_or(Self::DEFAULT_ARCHITECTURE)
57 }
58
59 pub fn vision_or_default(&self) -> &str {
61 self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
62 }
63
64 pub fn contributing_or_default(&self) -> &str {
66 self.contributing
67 .as_deref()
68 .unwrap_or(Self::DEFAULT_CONTRIBUTING)
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct Member {
74 pub capabilities: MemberCapabilities,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub verify_key: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub kdf_nonce: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub enrollment_verifier: Option<String>,
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
82 pub ai_delegations: BTreeMap<String, AiDelegationEntry>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub attestation: Option<Attestation>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Attestation {
95 pub attester: String,
98 pub signed_fields: AttestationSignedFields,
102 pub signed_at: chrono::DateTime<chrono::Utc>,
104 pub signature: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct AttestationSignedFields {
118 pub email: String,
119 pub capabilities: MemberCapabilities,
120 #[serde(default, rename = "otp_hash", skip_serializing_if = "Option::is_none")]
121 pub enrollment_verifier: Option<String>,
122}
123
124impl AttestationSignedFields {
125 pub fn canonical_bytes(&self) -> Vec<u8> {
130 serde_json::to_vec(self).expect("AttestationSignedFields canonicalization")
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct AiDelegationEntry {
151 pub delegation_verifier: String,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub delegation_salt: Option<String>,
159 pub created: chrono::DateTime<chrono::Utc>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub rotated: Option<chrono::DateTime<chrono::Utc>>,
164}
165
166#[derive(Debug, Clone, PartialEq)]
167pub enum MemberCapabilities {
168 All,
169 Specific(BTreeMap<Capability, CapabilityConfig>),
170}
171
172#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
173pub struct CapabilityConfig {
174 #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
175 pub max_mode: Option<InteractionLevel>,
176 #[serde(
177 rename = "max-cost-per-job",
178 default,
179 skip_serializing_if = "Option::is_none"
180 )]
181 pub max_cost_per_job: Option<f64>,
182}
183
184#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
191pub struct ModeDefaults {
192 #[serde(default)]
194 pub default: InteractionLevel,
195 #[serde(flatten, default)]
197 pub capabilities: BTreeMap<Capability, InteractionLevel>,
198}
199
200#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
203pub struct AiDefaults {
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub capabilities: Vec<Capability>,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum ModeSource {
211 Default,
213 Project,
215 Personal,
217 Item,
219 ProjectMax,
221}
222
223impl std::fmt::Display for ModeSource {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 match self {
226 Self::Default => write!(f, "default"),
227 Self::Project => write!(f, "project"),
228 Self::Personal => write!(f, "personal"),
229 Self::Item => write!(f, "item"),
230 Self::ProjectMax => write!(f, "project max"),
231 }
232 }
233}
234
235pub fn resolve_mode(
244 capability: &Capability,
245 raw_defaults: &ModeDefaults,
246 effective_defaults: &ModeDefaults,
247 personal_mode: Option<InteractionLevel>,
248 member_cap_config: Option<&CapabilityConfig>,
249) -> (InteractionLevel, ModeSource) {
250 let mut mode = effective_defaults.default;
252 let mut source = if effective_defaults.default != raw_defaults.default {
253 ModeSource::Project
254 } else {
255 ModeSource::Default
256 };
257
258 if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
260 mode = cap_mode;
261 let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
262 source = if from_raw {
263 ModeSource::Default
264 } else {
265 ModeSource::Project
266 };
267 }
268
269 if let Some(personal) = personal_mode {
271 mode = personal;
272 source = ModeSource::Personal;
273 }
274
275 if let Some(cap_config) = member_cap_config {
277 if let Some(max) = cap_config.max_mode {
278 if mode < max {
279 mode = max;
280 source = ModeSource::ProjectMax;
281 }
282 }
283 }
284
285 (mode, source)
286}
287
288impl Serialize for MemberCapabilities {
290 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
291 match self {
292 MemberCapabilities::All => serializer.serialize_str("all"),
293 MemberCapabilities::Specific(map) => map.serialize(serializer),
294 }
295 }
296}
297
298impl<'de> Deserialize<'de> for MemberCapabilities {
299 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
300 let value = serde_yaml_ng::Value::deserialize(deserializer)?;
301 match &value {
302 serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
303 serde_yaml_ng::Value::Mapping(_) => {
304 let map: BTreeMap<Capability, CapabilityConfig> =
305 serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
306 Ok(MemberCapabilities::Specific(map))
307 }
308 _ => Err(serde::de::Error::custom(
309 "expected \"all\" or a map of capabilities",
310 )),
311 }
312 }
313}
314
315impl Member {
316 pub fn new(capabilities: MemberCapabilities) -> Self {
318 Self {
319 capabilities,
320 verify_key: None,
321 kdf_nonce: None,
322 enrollment_verifier: None,
323 ai_delegations: BTreeMap::new(),
324 attestation: None,
325 }
326 }
327
328 pub fn has_capability(&self, cap: &Capability) -> bool {
330 match &self.capabilities {
331 MemberCapabilities::All => true,
332 MemberCapabilities::Specific(map) => map.contains_key(cap),
333 }
334 }
335}
336
337pub fn is_ai_member(id: &str) -> bool {
339 id.starts_with("ai:")
340}
341
342fn default_language() -> String {
343 "en".to_string()
344}
345
346impl Project {
347 pub fn new(name: String, acronym: Option<String>) -> Self {
348 Self {
349 name,
350 acronym,
351 description: None,
352 language: default_language(),
353 forge: None,
354 docs: Docs::default(),
355 members: BTreeMap::new(),
356 created: Utc::now(),
357 }
358 }
359}
360
361pub fn validate_acronym(value: &str) -> Result<String, String> {
368 let normalized = value.trim().to_uppercase();
369 if normalized.len() < 2 || normalized.len() > 8 {
370 return Err(format!(
371 "acronym must be 2-8 characters, got {} ('{}')",
372 normalized.len(),
373 normalized
374 ));
375 }
376 for (i, c) in normalized.chars().enumerate() {
377 if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
378 return Err(format!(
379 "acronym character '{c}' at position {i} is not A-Z or 0-9"
380 ));
381 }
382 }
383 Ok(normalized)
384}
385
386pub fn derive_acronym(name: &str) -> String {
390 let words: Vec<&str> = name.split_whitespace().collect();
391 if words.len() == 1 {
392 words[0]
393 .chars()
394 .filter(|c| c.is_alphanumeric())
395 .take(3)
396 .collect::<String>()
397 .to_uppercase()
398 } else {
399 words
400 .iter()
401 .filter_map(|w| w.chars().next())
402 .filter(|c| c.is_alphanumeric())
403 .take(4)
404 .collect::<String>()
405 .to_uppercase()
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn project_roundtrip() {
415 let project = Project::new("Test Project".into(), Some("TP".into()));
416 let yaml = serde_yaml_ng::to_string(&project).unwrap();
417 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
418 assert_eq!(project, parsed);
419 }
420
421 #[test]
426 fn ai_delegations_omitted_when_empty() {
427 let mut m = Member::new(MemberCapabilities::All);
428 assert!(m.ai_delegations.is_empty());
429 let yaml = serde_yaml_ng::to_string(&m).unwrap();
430 assert!(
431 !yaml.contains("ai_delegations"),
432 "empty ai_delegations should be skipped, got: {yaml}"
433 );
434 m.verify_key = Some("aa".repeat(32));
436 let yaml = serde_yaml_ng::to_string(&m).unwrap();
437 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
438 assert_eq!(m, parsed);
439 }
440
441 #[test]
442 fn ai_delegations_yaml_roundtrip() {
443 let mut m = Member::new(MemberCapabilities::All);
444 m.verify_key = Some("aa".repeat(32));
445 m.kdf_nonce = Some("bb".repeat(32));
446 m.ai_delegations.insert(
447 "ai:claude@joy".into(),
448 AiDelegationEntry {
449 delegation_verifier: "cc".repeat(32),
450 delegation_salt: None,
451 created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
452 .unwrap()
453 .with_timezone(&chrono::Utc),
454 rotated: None,
455 },
456 );
457 let yaml = serde_yaml_ng::to_string(&m).unwrap();
458 assert!(yaml.contains("ai_delegations:"));
459 assert!(yaml.contains("ai:claude@joy:"));
460 assert!(yaml.contains("delegation_verifier:"));
461 assert!(
462 !yaml.contains("delegation_salt:"),
463 "unset delegation_salt should be skipped (legacy entry)"
464 );
465 assert!(
466 !yaml.contains("rotated:"),
467 "unset rotated should be skipped"
468 );
469
470 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
471 assert_eq!(m, parsed);
472 }
473
474 #[test]
475 fn ai_delegations_with_rotated_roundtrips() {
476 let mut m = Member::new(MemberCapabilities::All);
477 let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
478 .unwrap()
479 .with_timezone(&chrono::Utc);
480 let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
481 .unwrap()
482 .with_timezone(&chrono::Utc);
483 m.ai_delegations.insert(
484 "ai:claude@joy".into(),
485 AiDelegationEntry {
486 delegation_verifier: "dd".repeat(32),
487 delegation_salt: None,
488 created,
489 rotated: Some(rotated),
490 },
491 );
492 let yaml = serde_yaml_ng::to_string(&m).unwrap();
493 assert!(yaml.contains("rotated:"));
494 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
495 assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
496 assert_eq!(parsed, m);
497 }
498
499 #[test]
504 fn attestation_omitted_when_none() {
505 let m = Member::new(MemberCapabilities::All);
506 let yaml = serde_yaml_ng::to_string(&m).unwrap();
507 assert!(!yaml.contains("attestation:"));
508 }
509
510 #[test]
511 fn attestation_yaml_roundtrips() {
512 let mut m = Member::new(MemberCapabilities::All);
513 m.attestation = Some(Attestation {
514 attester: "horst@example.com".into(),
515 signed_fields: AttestationSignedFields {
516 email: "alice@example.com".into(),
517 capabilities: MemberCapabilities::All,
518 enrollment_verifier: Some("ff".repeat(32)),
519 },
520 signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
521 .unwrap()
522 .with_timezone(&chrono::Utc),
523 signature: "aa".repeat(32),
524 });
525 let yaml = serde_yaml_ng::to_string(&m).unwrap();
526 assert!(yaml.contains("attestation:"));
527 assert!(yaml.contains("attester: horst@example.com"));
528 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
529 assert_eq!(parsed, m);
530 }
531
532 #[test]
533 fn attestation_signed_fields_canonical_is_deterministic() {
534 let a = AttestationSignedFields {
535 email: "alice@example.com".into(),
536 capabilities: MemberCapabilities::All,
537 enrollment_verifier: Some("abc".into()),
538 };
539 let b = a.clone();
540 assert_eq!(a.canonical_bytes(), b.canonical_bytes());
541 }
542
543 #[test]
544 fn attestation_signed_fields_differ_on_capability_change() {
545 let a = AttestationSignedFields {
546 email: "alice@example.com".into(),
547 capabilities: MemberCapabilities::All,
548 enrollment_verifier: None,
549 };
550 let mut caps = BTreeMap::new();
551 caps.insert(Capability::Implement, CapabilityConfig::default());
552 let b = AttestationSignedFields {
553 email: "alice@example.com".into(),
554 capabilities: MemberCapabilities::Specific(caps),
555 enrollment_verifier: None,
556 };
557 assert_ne!(a.canonical_bytes(), b.canonical_bytes());
558 }
559
560 #[test]
561 fn unknown_fields_from_legacy_yaml_are_ignored() {
562 let yaml = r#"
566capabilities: all
567public_key: aa
568salt: bb
569ai_tokens:
570 ai:claude@joy:
571 token_key: oldkey
572 created: "2026-03-28T22:00:00Z"
573ai_delegations:
574 ai:claude@joy:
575 delegation_verifier: newkey
576 created: "2026-04-15T10:00:00Z"
577"#;
578 let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
579 assert_eq!(
580 parsed.ai_delegations["ai:claude@joy"].delegation_verifier,
581 "newkey"
582 );
583 }
584
585 #[test]
590 fn docs_defaults_when_unset() {
591 let docs = Docs::default();
592 assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
593 assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
594 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
595 }
596
597 #[test]
598 fn docs_returns_configured_value() {
599 let docs = Docs {
600 architecture: Some("ARCHITECTURE.md".into()),
601 vision: Some("docs/product/vision.md".into()),
602 contributing: None,
603 };
604 assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
605 assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
606 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
607 }
608
609 #[test]
610 fn docs_omitted_from_yaml_when_empty() {
611 let project = Project::new("X".into(), None);
612 let yaml = serde_yaml_ng::to_string(&project).unwrap();
613 assert!(
614 !yaml.contains("docs:"),
615 "empty docs should be skipped, got: {yaml}"
616 );
617 }
618
619 #[test]
620 fn docs_present_in_yaml_when_set() {
621 let mut project = Project::new("X".into(), None);
622 project.docs.architecture = Some("ARCHITECTURE.md".into());
623 let yaml = serde_yaml_ng::to_string(&project).unwrap();
624 assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
625 assert!(yaml.contains("architecture: ARCHITECTURE.md"));
626 assert!(!yaml.contains("vision:"), "unset fields should be skipped");
627 }
628
629 #[test]
630 fn docs_yaml_roundtrip_with_overrides() {
631 let yaml = r#"
632name: Existing
633language: en
634docs:
635 architecture: ARCHITECTURE.md
636 contributing: docs/CONTRIBUTING.md
637created: 2026-01-01T00:00:00Z
638"#;
639 let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
640 assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
641 assert_eq!(parsed.docs.vision, None);
642 assert_eq!(
643 parsed.docs.contributing.as_deref(),
644 Some("docs/CONTRIBUTING.md")
645 );
646 assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
647 }
648
649 #[test]
650 fn derive_acronym_multi_word() {
651 assert_eq!(derive_acronym("My Cool Project"), "MCP");
652 }
653
654 #[test]
655 fn derive_acronym_single_word() {
656 assert_eq!(derive_acronym("Joy"), "JOY");
657 }
658
659 #[test]
660 fn derive_acronym_long_name() {
661 assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
662 }
663
664 #[test]
665 fn derive_acronym_single_long_word() {
666 assert_eq!(derive_acronym("Platform"), "PLA");
667 }
668
669 #[test]
674 fn validate_acronym_accepts_real_project_acronyms() {
675 for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
676 assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
677 }
678 }
679
680 #[test]
681 fn validate_acronym_accepts_alphanumeric() {
682 assert_eq!(validate_acronym("V2").unwrap(), "V2");
683 assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
684 }
685
686 #[test]
687 fn validate_acronym_normalizes_case_and_whitespace() {
688 assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
689 assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
690 assert_eq!(validate_acronym(" jyn ").unwrap(), "JYN");
691 }
692
693 #[test]
694 fn validate_acronym_rejects_too_short() {
695 assert!(validate_acronym("").is_err());
696 assert!(validate_acronym("J").is_err());
697 assert!(validate_acronym(" J ").is_err());
698 }
699
700 #[test]
701 fn validate_acronym_rejects_too_long() {
702 assert!(validate_acronym("ABCDEFGHI").is_err());
703 }
704
705 #[test]
706 fn validate_acronym_rejects_non_alnum() {
707 assert!(validate_acronym("JY-N").is_err());
708 assert!(validate_acronym("JY N").is_err());
709 assert!(validate_acronym("JY_N").is_err());
710 assert!(validate_acronym("JY.N").is_err());
711 }
712
713 #[test]
714 fn validate_acronym_rejects_non_ascii() {
715 assert!(validate_acronym("AEBC").is_ok());
716 assert!(validate_acronym("ABC").is_ok());
717 assert!(validate_acronym("\u{00c4}BC").is_err());
718 }
719
720 #[test]
725 fn mode_defaults_flat_yaml_roundtrip() {
726 let yaml = r#"
727default: interactive
728implement: collaborative
729review: pairing
730"#;
731 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
732 assert_eq!(parsed.default, InteractionLevel::Interactive);
733 assert_eq!(
734 parsed.capabilities[&Capability::Implement],
735 InteractionLevel::Collaborative
736 );
737 assert_eq!(
738 parsed.capabilities[&Capability::Review],
739 InteractionLevel::Pairing
740 );
741 }
742
743 #[test]
744 fn mode_defaults_empty_yaml() {
745 let yaml = "{}";
746 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
747 assert_eq!(parsed.default, InteractionLevel::Collaborative);
748 assert!(parsed.capabilities.is_empty());
749 }
750
751 #[test]
752 fn mode_defaults_only_default() {
753 let yaml = "default: pairing";
754 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
755 assert_eq!(parsed.default, InteractionLevel::Pairing);
756 assert!(parsed.capabilities.is_empty());
757 }
758
759 #[test]
760 fn ai_defaults_yaml_roundtrip() {
761 let yaml = r#"
762capabilities:
763 - implement
764 - review
765"#;
766 let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
767 assert_eq!(parsed.capabilities.len(), 2);
768 assert_eq!(parsed.capabilities[0], Capability::Implement);
769 }
770
771 fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
776 ModeDefaults {
777 default: mode,
778 ..Default::default()
779 }
780 }
781
782 fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
783 let mut d = ModeDefaults::default();
784 d.capabilities.insert(cap, mode);
785 d
786 }
787
788 #[test]
789 fn resolve_mode_uses_global_default() {
790 let raw = defaults_with_mode(InteractionLevel::Collaborative);
791 let effective = raw.clone();
792 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
793 assert_eq!(mode, InteractionLevel::Collaborative);
794 assert_eq!(source, ModeSource::Default);
795 }
796
797 #[test]
798 fn resolve_mode_uses_per_capability_default() {
799 let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
800 let effective = raw.clone();
801 let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
802 assert_eq!(mode, InteractionLevel::Interactive);
803 assert_eq!(source, ModeSource::Default);
804 }
805
806 #[test]
807 fn resolve_mode_project_override_detected() {
808 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
809 let effective =
810 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
811 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
812 assert_eq!(mode, InteractionLevel::Interactive);
813 assert_eq!(source, ModeSource::Project);
814 }
815
816 #[test]
817 fn resolve_mode_personal_overrides_default() {
818 let raw = defaults_with_mode(InteractionLevel::Collaborative);
819 let effective = raw.clone();
820 let (mode, source) = resolve_mode(
821 &Capability::Implement,
822 &raw,
823 &effective,
824 Some(InteractionLevel::Pairing),
825 None,
826 );
827 assert_eq!(mode, InteractionLevel::Pairing);
828 assert_eq!(source, ModeSource::Personal);
829 }
830
831 #[test]
832 fn resolve_mode_max_mode_clamps_upward() {
833 let raw = defaults_with_mode(InteractionLevel::Autonomous);
834 let effective = raw.clone();
835 let cap_config = CapabilityConfig {
836 max_mode: Some(InteractionLevel::Supervised),
837 ..Default::default()
838 };
839 let (mode, source) = resolve_mode(
840 &Capability::Implement,
841 &raw,
842 &effective,
843 None,
844 Some(&cap_config),
845 );
846 assert_eq!(mode, InteractionLevel::Supervised);
847 assert_eq!(source, ModeSource::ProjectMax);
848 }
849
850 #[test]
851 fn resolve_mode_max_mode_does_not_lower() {
852 let raw = defaults_with_mode(InteractionLevel::Pairing);
853 let effective = raw.clone();
854 let cap_config = CapabilityConfig {
855 max_mode: Some(InteractionLevel::Supervised),
856 ..Default::default()
857 };
858 let (mode, source) = resolve_mode(
859 &Capability::Implement,
860 &raw,
861 &effective,
862 None,
863 Some(&cap_config),
864 );
865 assert_eq!(mode, InteractionLevel::Pairing);
867 assert_eq!(source, ModeSource::Default);
868 }
869
870 #[test]
871 fn resolve_mode_personal_clamped_by_max() {
872 let raw = defaults_with_mode(InteractionLevel::Collaborative);
873 let effective = raw.clone();
874 let cap_config = CapabilityConfig {
875 max_mode: Some(InteractionLevel::Interactive),
876 ..Default::default()
877 };
878 let (mode, source) = resolve_mode(
879 &Capability::Implement,
880 &raw,
881 &effective,
882 Some(InteractionLevel::Autonomous),
883 Some(&cap_config),
884 );
885 assert_eq!(mode, InteractionLevel::Interactive);
887 assert_eq!(source, ModeSource::ProjectMax);
888 }
889
890 #[test]
895 fn item_mode_field_roundtrip() {
896 use crate::model::item::{Item, ItemType, Priority};
897
898 let mut item = Item::new(
899 "TST-0001".into(),
900 "Test".into(),
901 ItemType::Task,
902 Priority::Medium,
903 vec![],
904 );
905 item.mode = Some(InteractionLevel::Pairing);
906
907 let yaml = serde_yaml_ng::to_string(&item).unwrap();
908 assert!(yaml.contains("mode: pairing"), "mode field not serialized");
909
910 let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
911 assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
912 }
913
914 #[test]
915 fn item_mode_field_absent_when_none() {
916 use crate::model::item::{Item, ItemType, Priority};
917
918 let item = Item::new(
919 "TST-0002".into(),
920 "Test".into(),
921 ItemType::Task,
922 Priority::Medium,
923 vec![],
924 );
925 assert_eq!(item.mode, None);
926
927 let yaml = serde_yaml_ng::to_string(&item).unwrap();
928 assert!(
929 !yaml.contains("mode:"),
930 "mode field should not appear when None"
931 );
932 }
933
934 #[test]
935 fn item_mode_deserialized_from_existing_yaml() {
936 let yaml = r#"
937id: TST-0003
938title: Test
939type: task
940status: new
941priority: medium
942mode: interactive
943created: "2026-01-01T00:00:00+00:00"
944updated: "2026-01-01T00:00:00+00:00"
945"#;
946 let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
947 assert_eq!(item.mode, Some(InteractionLevel::Interactive));
948 }
949
950 #[test]
955 fn resolve_mode_full_scenario() {
956 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
958 let effective =
960 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
961 let personal = Some(InteractionLevel::Autonomous);
963 let cap_config = CapabilityConfig {
965 max_mode: Some(InteractionLevel::Supervised),
966 ..Default::default()
967 };
968
969 let (mode, source) = resolve_mode(
970 &Capability::Implement,
971 &raw,
972 &effective,
973 personal,
974 Some(&cap_config),
975 );
976
977 assert_eq!(mode, InteractionLevel::Supervised);
979 assert_eq!(source, ModeSource::ProjectMax);
980 }
981
982 #[test]
983 fn resolve_mode_all_layers_no_clamping() {
984 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
986 let effective =
988 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
989 let personal = Some(InteractionLevel::Pairing);
991 let cap_config = CapabilityConfig::default();
993
994 let (mode, source) = resolve_mode(
995 &Capability::Implement,
996 &raw,
997 &effective,
998 personal,
999 Some(&cap_config),
1000 );
1001
1002 assert_eq!(mode, InteractionLevel::Pairing);
1004 assert_eq!(source, ModeSource::Personal);
1005 }
1006}