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
12fn serialize_members<S>(
17 members: &BTreeMap<String, Member>,
18 serializer: S,
19) -> Result<S::Ok, S::Error>
20where
21 S: serde::Serializer,
22{
23 use serde::ser::SerializeMap;
24 let present = crate::member_ref::presentation_active();
25 let mut map = serializer.serialize_map(Some(members.len()))?;
26 for (k, v) in members {
27 if present {
28 map.serialize_entry(&crate::member_ref::resolve_str(k), v)?;
29 } else {
30 map.serialize_entry(k, v)?;
31 }
32 }
33 map.end()
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct Project {
38 pub name: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub acronym: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub description: Option<String>,
43 #[serde(default = "default_language")]
44 pub language: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub forge: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub privacy: Option<PrivacyMode>,
55 #[serde(default, skip_serializing_if = "Docs::is_empty")]
56 pub docs: Docs,
57 #[serde(
58 default,
59 skip_serializing_if = "BTreeMap::is_empty",
60 serialize_with = "serialize_members"
61 )]
62 pub members: BTreeMap<String, Member>,
63 #[serde(default, skip_serializing_if = "CryptConfig::is_empty")]
68 pub crypt: CryptConfig,
69 pub created: DateTime<Utc>,
70}
71
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum PrivacyMode {
79 #[default]
81 Open,
82 Anonymous,
85}
86
87impl std::fmt::Display for PrivacyMode {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Self::Open => write!(f, "open"),
91 Self::Anonymous => write!(f, "anonymous"),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101pub struct CryptConfig {
102 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
103 pub zones: BTreeMap<String, CryptZone>,
104}
105
106impl CryptConfig {
107 pub fn is_empty(&self) -> bool {
108 self.zones.is_empty()
109 }
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct CryptZone {
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub paths: Vec<String>,
124 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
135 pub delegations: BTreeMap<String, BTreeMap<String, String>>,
136}
137
138#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
142pub struct Docs {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub architecture: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub vision: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub contributing: Option<String>,
149}
150
151impl Docs {
152 pub const DEFAULT_ARCHITECTURE: &'static str = "docs/dev/architecture/README.md";
153 pub const DEFAULT_VISION: &'static str = "docs/dev/vision/README.md";
154 pub const DEFAULT_CONTRIBUTING: &'static str = "CONTRIBUTING.md";
155
156 pub fn is_empty(&self) -> bool {
157 self.architecture.is_none() && self.vision.is_none() && self.contributing.is_none()
158 }
159
160 pub fn architecture_or_default(&self) -> &str {
162 self.architecture
163 .as_deref()
164 .unwrap_or(Self::DEFAULT_ARCHITECTURE)
165 }
166
167 pub fn vision_or_default(&self) -> &str {
169 self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
170 }
171
172 pub fn contributing_or_default(&self) -> &str {
174 self.contributing
175 .as_deref()
176 .unwrap_or(Self::DEFAULT_CONTRIBUTING)
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct Member {
182 pub capabilities: MemberCapabilities,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub verify_key: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub kdf_nonce: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub seed_wrap_passphrase: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub seed_wrap_recovery: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub enrollment_verifier: Option<String>,
200 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
201 pub ai_delegations: BTreeMap<String, AiDelegationEntry>,
202 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
208 pub crypt_wraps: BTreeMap<String, String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub email_match: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub members_wrap: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub attestation: Option<Attestation>,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct Attestation {
233 pub attester: crate::member_ref::MemberRef,
236 pub signed_fields: AttestationSignedFields,
240 pub signed_at: chrono::DateTime<chrono::Utc>,
242 pub signature: String,
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct AttestationSignedFields {
256 pub email: String,
257 pub capabilities: MemberCapabilities,
258 #[serde(default, rename = "otp_hash", skip_serializing_if = "Option::is_none")]
259 pub enrollment_verifier: Option<String>,
260}
261
262impl AttestationSignedFields {
263 pub fn canonical_bytes(&self) -> Vec<u8> {
268 serde_json::to_vec(self).expect("AttestationSignedFields canonicalization")
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288pub struct AiDelegationEntry {
289 pub delegation_verifier: String,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub delegation_salt: Option<String>,
297 pub created: chrono::DateTime<chrono::Utc>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub rotated: Option<chrono::DateTime<chrono::Utc>>,
302}
303
304#[derive(Debug, Clone, PartialEq)]
305pub enum MemberCapabilities {
306 All,
307 Specific(BTreeMap<Capability, CapabilityConfig>),
308}
309
310#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
311pub struct CapabilityConfig {
312 #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
313 pub max_mode: Option<InteractionLevel>,
314 #[serde(
315 rename = "max-cost-per-job",
316 default,
317 skip_serializing_if = "Option::is_none"
318 )]
319 pub max_cost_per_job: Option<f64>,
320}
321
322#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
329pub struct ModeDefaults {
330 #[serde(default)]
332 pub default: InteractionLevel,
333 #[serde(flatten, default)]
335 pub capabilities: BTreeMap<Capability, InteractionLevel>,
336}
337
338#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
341pub struct AiDefaults {
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub capabilities: Vec<Capability>,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum ModeSource {
349 Default,
351 Project,
353 Personal,
355 Item,
357 ProjectMax,
359}
360
361impl std::fmt::Display for ModeSource {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 match self {
364 Self::Default => write!(f, "default"),
365 Self::Project => write!(f, "project"),
366 Self::Personal => write!(f, "personal"),
367 Self::Item => write!(f, "item"),
368 Self::ProjectMax => write!(f, "project max"),
369 }
370 }
371}
372
373pub fn resolve_mode(
382 capability: &Capability,
383 raw_defaults: &ModeDefaults,
384 effective_defaults: &ModeDefaults,
385 personal_mode: Option<InteractionLevel>,
386 member_cap_config: Option<&CapabilityConfig>,
387) -> (InteractionLevel, ModeSource) {
388 let mut mode = effective_defaults.default;
390 let mut source = if effective_defaults.default != raw_defaults.default {
391 ModeSource::Project
392 } else {
393 ModeSource::Default
394 };
395
396 if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
398 mode = cap_mode;
399 let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
400 source = if from_raw {
401 ModeSource::Default
402 } else {
403 ModeSource::Project
404 };
405 }
406
407 if let Some(personal) = personal_mode {
409 mode = personal;
410 source = ModeSource::Personal;
411 }
412
413 if let Some(cap_config) = member_cap_config {
415 if let Some(max) = cap_config.max_mode {
416 if mode < max {
417 mode = max;
418 source = ModeSource::ProjectMax;
419 }
420 }
421 }
422
423 (mode, source)
424}
425
426impl Serialize for MemberCapabilities {
428 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
429 match self {
430 MemberCapabilities::All => serializer.serialize_str("all"),
431 MemberCapabilities::Specific(map) => map.serialize(serializer),
432 }
433 }
434}
435
436impl<'de> Deserialize<'de> for MemberCapabilities {
437 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
438 let value = serde_yaml_ng::Value::deserialize(deserializer)?;
439 match &value {
440 serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
441 serde_yaml_ng::Value::Mapping(_) => {
442 let map: BTreeMap<Capability, CapabilityConfig> =
443 serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
444 Ok(MemberCapabilities::Specific(map))
445 }
446 _ => Err(serde::de::Error::custom(
447 "expected \"all\" or a map of capabilities",
448 )),
449 }
450 }
451}
452
453impl Member {
454 pub fn new(capabilities: MemberCapabilities) -> Self {
456 Self {
457 capabilities,
458 verify_key: None,
459 kdf_nonce: None,
460 seed_wrap_passphrase: None,
461 seed_wrap_recovery: None,
462 enrollment_verifier: None,
463 ai_delegations: BTreeMap::new(),
464 crypt_wraps: BTreeMap::new(),
465 email_match: None,
466 members_wrap: None,
467 attestation: None,
468 }
469 }
470
471 pub fn has_capability(&self, cap: &Capability) -> bool {
473 match &self.capabilities {
474 MemberCapabilities::All => true,
475 MemberCapabilities::Specific(map) => map.contains_key(cap),
476 }
477 }
478}
479
480pub fn is_ai_member(id: &str) -> bool {
482 id.starts_with("ai:")
483}
484
485pub fn describe_value(key: &str, _value: &serde_json::Value) -> Option<String> {
490 let text = match key {
491 "name" => "human-readable project name",
492 "acronym" => "short prefix used in item IDs",
493 "description" => "one-paragraph project description",
494 "language" => "project language for written artifacts (titles, comments, commits)",
495 "forge" => {
496 "release forge override (e.g. github, none); unset = auto-detect from git remotes"
497 }
498 "privacy" => {
499 "member-PII privacy mode: none (default, behaves as open), open, or anonymous (e-mail in an encrypted members.yaml, opaque ids in project.yaml)"
500 }
501 "release.version-files" => {
502 "paths whose version strings `joy release bump` rewrites; managed with `joy project set release.version-files --add/--rm/<csv>`"
503 }
504 "created" => "ISO timestamp when the project was initialized",
505 "docs.architecture" => "path to the technical architecture document",
506 "docs.vision" => "path to the product-vision document",
507 "docs.contributing" => "path to the contributing guide",
508 _ => return None,
509 };
510 Some(text.to_string())
511}
512
513fn default_language() -> String {
514 "en".to_string()
515}
516
517impl Project {
518 pub fn new(name: String, acronym: Option<String>) -> Self {
519 Self {
520 name,
521 acronym,
522 description: None,
523 language: default_language(),
524 forge: None,
525 privacy: None,
526 docs: Docs::default(),
527 members: BTreeMap::new(),
528 crypt: CryptConfig::default(),
529 created: Utc::now(),
530 }
531 }
532
533 pub fn privacy_mode(&self) -> PrivacyMode {
535 self.privacy.unwrap_or_default()
536 }
537}
538
539pub fn validate_acronym(value: &str) -> Result<String, String> {
546 let normalized = value.trim().to_uppercase();
547 if normalized.len() < 2 || normalized.len() > 8 {
548 return Err(format!(
549 "acronym must be 2-8 characters, got {} ('{}')",
550 normalized.len(),
551 normalized
552 ));
553 }
554 for (i, c) in normalized.chars().enumerate() {
555 if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
556 return Err(format!(
557 "acronym character '{c}' at position {i} is not A-Z or 0-9"
558 ));
559 }
560 }
561 Ok(normalized)
562}
563
564pub fn derive_acronym(name: &str) -> String {
568 let words: Vec<&str> = name.split_whitespace().collect();
569 if words.len() == 1 {
570 words[0]
571 .chars()
572 .filter(|c| c.is_alphanumeric())
573 .take(3)
574 .collect::<String>()
575 .to_uppercase()
576 } else {
577 words
578 .iter()
579 .filter_map(|w| w.chars().next())
580 .filter(|c| c.is_alphanumeric())
581 .take(4)
582 .collect::<String>()
583 .to_uppercase()
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn privacy_mode_defaults_to_open() {
593 let project = Project::new("T".into(), Some("T".into()));
594 assert_eq!(project.privacy, None);
595 assert_eq!(project.privacy_mode(), PrivacyMode::Open);
596 }
597
598 #[test]
599 fn privacy_absent_from_yaml_by_default() {
600 let project = Project::new("T".into(), Some("T".into()));
601 let yaml = serde_yaml_ng::to_string(&project).unwrap();
602 assert!(
603 !yaml.contains("privacy"),
604 "none (absent) is the default; got:\n{yaml}"
605 );
606 }
607
608 #[test]
609 fn privacy_open_serializes_explicitly() {
610 let mut project = Project::new("T".into(), Some("T".into()));
611 project.privacy = Some(PrivacyMode::Open);
612 let yaml = serde_yaml_ng::to_string(&project).unwrap();
613 assert!(yaml.contains("privacy: open"), "got:\n{yaml}");
614 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
615 assert_eq!(parsed.privacy, Some(PrivacyMode::Open));
616 }
617
618 #[test]
619 fn privacy_mode_accessor_maps_none_and_open_to_open() {
620 let mut project = Project::new("T".into(), Some("T".into()));
621 assert_eq!(project.privacy_mode(), PrivacyMode::Open);
622 project.privacy = Some(PrivacyMode::Open);
623 assert_eq!(project.privacy_mode(), PrivacyMode::Open);
624 project.privacy = Some(PrivacyMode::Anonymous);
625 assert_eq!(project.privacy_mode(), PrivacyMode::Anonymous);
626 }
627
628 #[test]
629 fn privacy_anonymous_roundtrips() {
630 let mut project = Project::new("T".into(), Some("T".into()));
631 project.privacy = Some(PrivacyMode::Anonymous);
632 let yaml = serde_yaml_ng::to_string(&project).unwrap();
633 assert!(yaml.contains("privacy: anonymous"), "got:\n{yaml}");
634 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
635 assert_eq!(parsed.privacy, Some(PrivacyMode::Anonymous));
636 assert_eq!(parsed.privacy_mode(), PrivacyMode::Anonymous);
637 }
638
639 #[test]
640 fn privacy_mode_display() {
641 assert_eq!(PrivacyMode::Open.to_string(), "open");
642 assert_eq!(PrivacyMode::Anonymous.to_string(), "anonymous");
643 }
644
645 #[test]
646 fn project_roundtrip() {
647 let project = Project::new("Test Project".into(), Some("TP".into()));
648 let yaml = serde_yaml_ng::to_string(&project).unwrap();
649 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
650 assert_eq!(project, parsed);
651 }
652
653 #[test]
654 fn describe_value_covers_documented_keys() {
655 let dummy = serde_json::Value::Null;
656 for key in &[
657 "name",
658 "acronym",
659 "description",
660 "language",
661 "forge",
662 "release.version-files",
663 "created",
664 "docs.architecture",
665 "docs.vision",
666 "docs.contributing",
667 ] {
668 assert!(
669 describe_value(key, &dummy).is_some(),
670 "missing description for project key {key}"
671 );
672 }
673 assert!(describe_value("unknown", &dummy).is_none());
674 }
675
676 #[test]
681 fn ai_delegations_omitted_when_empty() {
682 let mut m = Member::new(MemberCapabilities::All);
683 assert!(m.ai_delegations.is_empty());
684 let yaml = serde_yaml_ng::to_string(&m).unwrap();
685 assert!(
686 !yaml.contains("ai_delegations"),
687 "empty ai_delegations should be skipped, got: {yaml}"
688 );
689 m.verify_key = Some("aa".repeat(32));
691 let yaml = serde_yaml_ng::to_string(&m).unwrap();
692 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
693 assert_eq!(m, parsed);
694 }
695
696 #[test]
697 fn ai_delegations_yaml_roundtrip() {
698 let mut m = Member::new(MemberCapabilities::All);
699 m.verify_key = Some("aa".repeat(32));
700 m.kdf_nonce = Some("bb".repeat(32));
701 m.ai_delegations.insert(
702 "ai:claude@joy".into(),
703 AiDelegationEntry {
704 delegation_verifier: "cc".repeat(32),
705 delegation_salt: None,
706 created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
707 .unwrap()
708 .with_timezone(&chrono::Utc),
709 rotated: None,
710 },
711 );
712 let yaml = serde_yaml_ng::to_string(&m).unwrap();
713 assert!(yaml.contains("ai_delegations:"));
714 assert!(yaml.contains("ai:claude@joy:"));
715 assert!(yaml.contains("delegation_verifier:"));
716 assert!(
717 !yaml.contains("delegation_salt:"),
718 "unset delegation_salt should be skipped (legacy entry)"
719 );
720 assert!(
721 !yaml.contains("rotated:"),
722 "unset rotated should be skipped"
723 );
724
725 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
726 assert_eq!(m, parsed);
727 }
728
729 #[test]
730 fn ai_delegations_with_rotated_roundtrips() {
731 let mut m = Member::new(MemberCapabilities::All);
732 let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
733 .unwrap()
734 .with_timezone(&chrono::Utc);
735 let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
736 .unwrap()
737 .with_timezone(&chrono::Utc);
738 m.ai_delegations.insert(
739 "ai:claude@joy".into(),
740 AiDelegationEntry {
741 delegation_verifier: "dd".repeat(32),
742 delegation_salt: None,
743 created,
744 rotated: Some(rotated),
745 },
746 );
747 let yaml = serde_yaml_ng::to_string(&m).unwrap();
748 assert!(yaml.contains("rotated:"));
749 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
750 assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
751 assert_eq!(parsed, m);
752 }
753
754 #[test]
759 fn attestation_omitted_when_none() {
760 let m = Member::new(MemberCapabilities::All);
761 let yaml = serde_yaml_ng::to_string(&m).unwrap();
762 assert!(!yaml.contains("attestation:"));
763 }
764
765 #[test]
766 fn attestation_yaml_roundtrips() {
767 let mut m = Member::new(MemberCapabilities::All);
768 m.attestation = Some(Attestation {
769 attester: "horst@example.com".into(),
770 signed_fields: AttestationSignedFields {
771 email: "alice@example.com".into(),
772 capabilities: MemberCapabilities::All,
773 enrollment_verifier: Some("ff".repeat(32)),
774 },
775 signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
776 .unwrap()
777 .with_timezone(&chrono::Utc),
778 signature: "aa".repeat(32),
779 });
780 let yaml = serde_yaml_ng::to_string(&m).unwrap();
781 assert!(yaml.contains("attestation:"));
782 assert!(yaml.contains("attester: horst@example.com"));
783 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
784 assert_eq!(parsed, m);
785 }
786
787 #[test]
788 fn attestation_signed_fields_canonical_is_deterministic() {
789 let a = AttestationSignedFields {
790 email: "alice@example.com".into(),
791 capabilities: MemberCapabilities::All,
792 enrollment_verifier: Some("abc".into()),
793 };
794 let b = a.clone();
795 assert_eq!(a.canonical_bytes(), b.canonical_bytes());
796 }
797
798 #[test]
799 fn attestation_signed_fields_differ_on_capability_change() {
800 let a = AttestationSignedFields {
801 email: "alice@example.com".into(),
802 capabilities: MemberCapabilities::All,
803 enrollment_verifier: None,
804 };
805 let mut caps = BTreeMap::new();
806 caps.insert(Capability::Implement, CapabilityConfig::default());
807 let b = AttestationSignedFields {
808 email: "alice@example.com".into(),
809 capabilities: MemberCapabilities::Specific(caps),
810 enrollment_verifier: None,
811 };
812 assert_ne!(a.canonical_bytes(), b.canonical_bytes());
813 }
814
815 #[test]
816 fn unknown_fields_from_legacy_yaml_are_ignored() {
817 let yaml = r#"
821capabilities: all
822public_key: aa
823salt: bb
824ai_tokens:
825 ai:claude@joy:
826 token_key: oldkey
827 created: "2026-03-28T22:00:00Z"
828ai_delegations:
829 ai:claude@joy:
830 delegation_verifier: newkey
831 created: "2026-04-15T10:00:00Z"
832"#;
833 let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
834 assert_eq!(
835 parsed.ai_delegations["ai:claude@joy"].delegation_verifier,
836 "newkey"
837 );
838 }
839
840 #[test]
845 fn docs_defaults_when_unset() {
846 let docs = Docs::default();
847 assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
848 assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
849 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
850 }
851
852 #[test]
853 fn docs_returns_configured_value() {
854 let docs = Docs {
855 architecture: Some("ARCHITECTURE.md".into()),
856 vision: Some("docs/product/vision.md".into()),
857 contributing: None,
858 };
859 assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
860 assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
861 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
862 }
863
864 #[test]
865 fn docs_omitted_from_yaml_when_empty() {
866 let project = Project::new("X".into(), None);
867 let yaml = serde_yaml_ng::to_string(&project).unwrap();
868 assert!(
869 !yaml.contains("docs:"),
870 "empty docs should be skipped, got: {yaml}"
871 );
872 }
873
874 #[test]
875 fn docs_present_in_yaml_when_set() {
876 let mut project = Project::new("X".into(), None);
877 project.docs.architecture = Some("ARCHITECTURE.md".into());
878 let yaml = serde_yaml_ng::to_string(&project).unwrap();
879 assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
880 assert!(yaml.contains("architecture: ARCHITECTURE.md"));
881 assert!(!yaml.contains("vision:"), "unset fields should be skipped");
882 }
883
884 #[test]
885 fn docs_yaml_roundtrip_with_overrides() {
886 let yaml = r#"
887name: Existing
888language: en
889docs:
890 architecture: ARCHITECTURE.md
891 contributing: docs/CONTRIBUTING.md
892created: 2026-01-01T00:00:00Z
893"#;
894 let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
895 assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
896 assert_eq!(parsed.docs.vision, None);
897 assert_eq!(
898 parsed.docs.contributing.as_deref(),
899 Some("docs/CONTRIBUTING.md")
900 );
901 assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
902 }
903
904 #[test]
905 fn derive_acronym_multi_word() {
906 assert_eq!(derive_acronym("My Cool Project"), "MCP");
907 }
908
909 #[test]
910 fn derive_acronym_single_word() {
911 assert_eq!(derive_acronym("Joy"), "JOY");
912 }
913
914 #[test]
915 fn derive_acronym_long_name() {
916 assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
917 }
918
919 #[test]
920 fn derive_acronym_single_long_word() {
921 assert_eq!(derive_acronym("Platform"), "PLA");
922 }
923
924 #[test]
929 fn validate_acronym_accepts_real_project_acronyms() {
930 for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
931 assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
932 }
933 }
934
935 #[test]
936 fn validate_acronym_accepts_alphanumeric() {
937 assert_eq!(validate_acronym("V2").unwrap(), "V2");
938 assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
939 }
940
941 #[test]
942 fn validate_acronym_normalizes_case_and_whitespace() {
943 assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
944 assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
945 assert_eq!(validate_acronym(" jyn ").unwrap(), "JYN");
946 }
947
948 #[test]
949 fn validate_acronym_rejects_too_short() {
950 assert!(validate_acronym("").is_err());
951 assert!(validate_acronym("J").is_err());
952 assert!(validate_acronym(" J ").is_err());
953 }
954
955 #[test]
956 fn validate_acronym_rejects_too_long() {
957 assert!(validate_acronym("ABCDEFGHI").is_err());
958 }
959
960 #[test]
961 fn validate_acronym_rejects_non_alnum() {
962 assert!(validate_acronym("JY-N").is_err());
963 assert!(validate_acronym("JY N").is_err());
964 assert!(validate_acronym("JY_N").is_err());
965 assert!(validate_acronym("JY.N").is_err());
966 }
967
968 #[test]
969 fn validate_acronym_rejects_non_ascii() {
970 assert!(validate_acronym("AEBC").is_ok());
971 assert!(validate_acronym("ABC").is_ok());
972 assert!(validate_acronym("\u{00c4}BC").is_err());
973 }
974
975 #[test]
980 fn mode_defaults_flat_yaml_roundtrip() {
981 let yaml = r#"
982default: interactive
983implement: collaborative
984review: pairing
985"#;
986 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
987 assert_eq!(parsed.default, InteractionLevel::Interactive);
988 assert_eq!(
989 parsed.capabilities[&Capability::Implement],
990 InteractionLevel::Collaborative
991 );
992 assert_eq!(
993 parsed.capabilities[&Capability::Review],
994 InteractionLevel::Pairing
995 );
996 }
997
998 #[test]
999 fn mode_defaults_empty_yaml() {
1000 let yaml = "{}";
1001 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
1002 assert_eq!(parsed.default, InteractionLevel::Collaborative);
1003 assert!(parsed.capabilities.is_empty());
1004 }
1005
1006 #[test]
1007 fn mode_defaults_only_default() {
1008 let yaml = "default: pairing";
1009 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
1010 assert_eq!(parsed.default, InteractionLevel::Pairing);
1011 assert!(parsed.capabilities.is_empty());
1012 }
1013
1014 #[test]
1015 fn ai_defaults_yaml_roundtrip() {
1016 let yaml = r#"
1017capabilities:
1018 - implement
1019 - review
1020"#;
1021 let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
1022 assert_eq!(parsed.capabilities.len(), 2);
1023 assert_eq!(parsed.capabilities[0], Capability::Implement);
1024 }
1025
1026 fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
1031 ModeDefaults {
1032 default: mode,
1033 ..Default::default()
1034 }
1035 }
1036
1037 fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
1038 let mut d = ModeDefaults::default();
1039 d.capabilities.insert(cap, mode);
1040 d
1041 }
1042
1043 #[test]
1044 fn resolve_mode_uses_global_default() {
1045 let raw = defaults_with_mode(InteractionLevel::Collaborative);
1046 let effective = raw.clone();
1047 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
1048 assert_eq!(mode, InteractionLevel::Collaborative);
1049 assert_eq!(source, ModeSource::Default);
1050 }
1051
1052 #[test]
1053 fn resolve_mode_uses_per_capability_default() {
1054 let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
1055 let effective = raw.clone();
1056 let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
1057 assert_eq!(mode, InteractionLevel::Interactive);
1058 assert_eq!(source, ModeSource::Default);
1059 }
1060
1061 #[test]
1062 fn resolve_mode_project_override_detected() {
1063 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1064 let effective =
1065 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1066 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
1067 assert_eq!(mode, InteractionLevel::Interactive);
1068 assert_eq!(source, ModeSource::Project);
1069 }
1070
1071 #[test]
1072 fn resolve_mode_personal_overrides_default() {
1073 let raw = defaults_with_mode(InteractionLevel::Collaborative);
1074 let effective = raw.clone();
1075 let (mode, source) = resolve_mode(
1076 &Capability::Implement,
1077 &raw,
1078 &effective,
1079 Some(InteractionLevel::Pairing),
1080 None,
1081 );
1082 assert_eq!(mode, InteractionLevel::Pairing);
1083 assert_eq!(source, ModeSource::Personal);
1084 }
1085
1086 #[test]
1087 fn resolve_mode_max_mode_clamps_upward() {
1088 let raw = defaults_with_mode(InteractionLevel::Autonomous);
1089 let effective = raw.clone();
1090 let cap_config = CapabilityConfig {
1091 max_mode: Some(InteractionLevel::Supervised),
1092 ..Default::default()
1093 };
1094 let (mode, source) = resolve_mode(
1095 &Capability::Implement,
1096 &raw,
1097 &effective,
1098 None,
1099 Some(&cap_config),
1100 );
1101 assert_eq!(mode, InteractionLevel::Supervised);
1102 assert_eq!(source, ModeSource::ProjectMax);
1103 }
1104
1105 #[test]
1106 fn resolve_mode_max_mode_does_not_lower() {
1107 let raw = defaults_with_mode(InteractionLevel::Pairing);
1108 let effective = raw.clone();
1109 let cap_config = CapabilityConfig {
1110 max_mode: Some(InteractionLevel::Supervised),
1111 ..Default::default()
1112 };
1113 let (mode, source) = resolve_mode(
1114 &Capability::Implement,
1115 &raw,
1116 &effective,
1117 None,
1118 Some(&cap_config),
1119 );
1120 assert_eq!(mode, InteractionLevel::Pairing);
1122 assert_eq!(source, ModeSource::Default);
1123 }
1124
1125 #[test]
1126 fn resolve_mode_personal_clamped_by_max() {
1127 let raw = defaults_with_mode(InteractionLevel::Collaborative);
1128 let effective = raw.clone();
1129 let cap_config = CapabilityConfig {
1130 max_mode: Some(InteractionLevel::Interactive),
1131 ..Default::default()
1132 };
1133 let (mode, source) = resolve_mode(
1134 &Capability::Implement,
1135 &raw,
1136 &effective,
1137 Some(InteractionLevel::Autonomous),
1138 Some(&cap_config),
1139 );
1140 assert_eq!(mode, InteractionLevel::Interactive);
1142 assert_eq!(source, ModeSource::ProjectMax);
1143 }
1144
1145 #[test]
1150 fn item_mode_field_roundtrip() {
1151 use crate::model::item::{Item, ItemType, Priority};
1152
1153 let mut item = Item::new(
1154 "TST-0001".into(),
1155 "Test".into(),
1156 ItemType::Task,
1157 Priority::Medium,
1158 vec![],
1159 );
1160 item.mode = Some(InteractionLevel::Pairing);
1161
1162 let yaml = serde_yaml_ng::to_string(&item).unwrap();
1163 assert!(yaml.contains("mode: pairing"), "mode field not serialized");
1164
1165 let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
1166 assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
1167 }
1168
1169 #[test]
1170 fn item_mode_field_absent_when_none() {
1171 use crate::model::item::{Item, ItemType, Priority};
1172
1173 let item = Item::new(
1174 "TST-0002".into(),
1175 "Test".into(),
1176 ItemType::Task,
1177 Priority::Medium,
1178 vec![],
1179 );
1180 assert_eq!(item.mode, None);
1181
1182 let yaml = serde_yaml_ng::to_string(&item).unwrap();
1183 assert!(
1184 !yaml.contains("mode:"),
1185 "mode field should not appear when None"
1186 );
1187 }
1188
1189 #[test]
1190 fn item_mode_deserialized_from_existing_yaml() {
1191 let yaml = r#"
1192id: TST-0003
1193title: Test
1194type: task
1195status: new
1196priority: medium
1197mode: interactive
1198created: "2026-01-01T00:00:00+00:00"
1199updated: "2026-01-01T00:00:00+00:00"
1200"#;
1201 let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
1202 assert_eq!(item.mode, Some(InteractionLevel::Interactive));
1203 }
1204
1205 #[test]
1210 fn resolve_mode_full_scenario() {
1211 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1213 let effective =
1215 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1216 let personal = Some(InteractionLevel::Autonomous);
1218 let cap_config = CapabilityConfig {
1220 max_mode: Some(InteractionLevel::Supervised),
1221 ..Default::default()
1222 };
1223
1224 let (mode, source) = resolve_mode(
1225 &Capability::Implement,
1226 &raw,
1227 &effective,
1228 personal,
1229 Some(&cap_config),
1230 );
1231
1232 assert_eq!(mode, InteractionLevel::Supervised);
1234 assert_eq!(source, ModeSource::ProjectMax);
1235 }
1236
1237 #[test]
1238 fn resolve_mode_all_layers_no_clamping() {
1239 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1241 let effective =
1243 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1244 let personal = Some(InteractionLevel::Pairing);
1246 let cap_config = CapabilityConfig::default();
1248
1249 let (mode, source) = resolve_mode(
1250 &Capability::Implement,
1251 &raw,
1252 &effective,
1253 personal,
1254 Some(&cap_config),
1255 );
1256
1257 assert_eq!(mode, InteractionLevel::Pairing);
1259 assert_eq!(source, ModeSource::Personal);
1260 }
1261}