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