Skip to main content

joy_core/model/
project.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use 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/// Configurable paths to the project's reference documentation, relative to
31/// the project root. Used by `joy ai init` to support existing repos with
32/// non-default doc layouts and read by AI tools via `joy project get docs.<key>`.
33#[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    /// Configured architecture path or the default if unset.
53    pub fn architecture_or_default(&self) -> &str {
54        self.architecture
55            .as_deref()
56            .unwrap_or(Self::DEFAULT_ARCHITECTURE)
57    }
58
59    /// Configured vision path or the default if unset.
60    pub fn vision_or_default(&self) -> &str {
61        self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
62    }
63
64    /// Configured contributing path or the default if unset.
65    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 public_key: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub salt: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub otp_hash: 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/// Per-member attestation: a signature by a manage member over a stable
88/// subset of the member's fields (email, capabilities, otp_hash). Verified
89/// locally against project.yaml by looking up the attester's public_key in
90/// the same file. The founder is the sole member allowed to have no
91/// attestation in a fresh project; once any additional manage member is
92/// added, that member implicitly reverse-attests the founder.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Attestation {
95    /// Email (or AI member id) of the member who produced the signature.
96    /// Must be a manage-capable member at signing time.
97    pub attester: String,
98    /// The fields this signature covers. public_key is intentionally
99    /// excluded so that passphrase changes do not break existing
100    /// attestations.
101    pub signed_fields: AttestationSignedFields,
102    /// When the attestation was produced.
103    pub signed_at: chrono::DateTime<chrono::Utc>,
104    /// Hex-encoded Ed25519 signature over the canonical serialization of
105    /// `signed_fields`.
106    pub signature: String,
107}
108
109/// The exact subset of a member's state covered by the attestation
110/// signature. Changes to any of these fields invalidate the signature.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct AttestationSignedFields {
113    pub email: String,
114    pub capabilities: MemberCapabilities,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub otp_hash: Option<String>,
117}
118
119impl AttestationSignedFields {
120    /// Produce a deterministic byte sequence for signing/verification.
121    /// Stability relies on: (a) BTreeMap ordering in MemberCapabilities::Specific,
122    /// (b) struct field declaration order via serde_json, (c) skip-empty rules
123    /// being identical on write and read.
124    pub fn canonical_bytes(&self) -> Vec<u8> {
125        serde_json::to_vec(self).expect("AttestationSignedFields canonicalization")
126    }
127}
128
129/// A stable per-(human, AI) delegation key (ADR-033). The matching private
130/// key lives off-repo at
131/// `~/.local/state/joy/delegations/<project>/<ai-member>.key`.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct AiDelegationEntry {
134    /// Public key of the stable delegation keypair (hex-encoded Ed25519).
135    pub delegation_key: String,
136    /// When this delegation was first issued.
137    pub created: chrono::DateTime<chrono::Utc>,
138    /// When this delegation was last rotated, if ever.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub rotated: Option<chrono::DateTime<chrono::Utc>>,
141}
142
143#[derive(Debug, Clone, PartialEq)]
144pub enum MemberCapabilities {
145    All,
146    Specific(BTreeMap<Capability, CapabilityConfig>),
147}
148
149#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
150pub struct CapabilityConfig {
151    #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
152    pub max_mode: Option<InteractionLevel>,
153    #[serde(
154        rename = "max-cost-per-job",
155        default,
156        skip_serializing_if = "Option::is_none"
157    )]
158    pub max_cost_per_job: Option<f64>,
159}
160
161// ---------------------------------------------------------------------------
162// Mode defaults (from project.defaults.yaml, overridable in project.yaml)
163// ---------------------------------------------------------------------------
164
165/// Interaction mode defaults: a global default plus optional per-capability overrides.
166/// Deserializes from flat YAML like: `{ default: collaborative, implement: autonomous }`.
167#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
168pub struct ModeDefaults {
169    /// Fallback mode when no per-capability mode is set.
170    #[serde(default)]
171    pub default: InteractionLevel,
172    /// Per-capability mode overrides (flattened into the same map).
173    #[serde(flatten, default)]
174    pub capabilities: BTreeMap<Capability, InteractionLevel>,
175}
176
177/// Default capabilities granted to AI members by joy ai init.
178/// Loaded from `ai-defaults.capabilities` in project.defaults.yaml.
179#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
180pub struct AiDefaults {
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub capabilities: Vec<Capability>,
183}
184
185/// Source of a resolved interaction mode.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum ModeSource {
188    /// From project.defaults.yaml (Joy's recommendation).
189    Default,
190    /// From project.yaml agents.defaults override.
191    Project,
192    /// From config.yaml personal preference.
193    Personal,
194    /// From item-level override (future).
195    Item,
196    /// Clamped by max-mode from project.yaml member config.
197    ProjectMax,
198}
199
200impl std::fmt::Display for ModeSource {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        match self {
203            Self::Default => write!(f, "default"),
204            Self::Project => write!(f, "project"),
205            Self::Personal => write!(f, "personal"),
206            Self::Item => write!(f, "item"),
207            Self::ProjectMax => write!(f, "project max"),
208        }
209    }
210}
211
212/// Resolve the effective interaction mode for a given capability.
213///
214/// Resolution order (later wins):
215/// 1. Effective defaults global mode (project.defaults.yaml merged with project.yaml)
216/// 2. Effective defaults per-capability mode
217/// 3. Personal config preference
218///
219/// All clamped by max-mode from the member's CapabilityConfig.
220pub fn resolve_mode(
221    capability: &Capability,
222    raw_defaults: &ModeDefaults,
223    effective_defaults: &ModeDefaults,
224    personal_mode: Option<InteractionLevel>,
225    member_cap_config: Option<&CapabilityConfig>,
226) -> (InteractionLevel, ModeSource) {
227    // 1. Global fallback from effective defaults
228    let mut mode = effective_defaults.default;
229    let mut source = if effective_defaults.default != raw_defaults.default {
230        ModeSource::Project
231    } else {
232        ModeSource::Default
233    };
234
235    // 2. Per-capability default
236    if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
237        mode = cap_mode;
238        let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
239        source = if from_raw {
240            ModeSource::Default
241        } else {
242            ModeSource::Project
243        };
244    }
245
246    // 3. Personal preference
247    if let Some(personal) = personal_mode {
248        mode = personal;
249        source = ModeSource::Personal;
250    }
251
252    // 4. Clamp by max-mode (minimum interactivity required)
253    if let Some(cap_config) = member_cap_config {
254        if let Some(max) = cap_config.max_mode {
255            if mode < max {
256                mode = max;
257                source = ModeSource::ProjectMax;
258            }
259        }
260    }
261
262    (mode, source)
263}
264
265// Custom serde for MemberCapabilities: "all" string or map of capabilities
266impl Serialize for MemberCapabilities {
267    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268        match self {
269            MemberCapabilities::All => serializer.serialize_str("all"),
270            MemberCapabilities::Specific(map) => map.serialize(serializer),
271        }
272    }
273}
274
275impl<'de> Deserialize<'de> for MemberCapabilities {
276    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
277        let value = serde_yaml_ng::Value::deserialize(deserializer)?;
278        match &value {
279            serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
280            serde_yaml_ng::Value::Mapping(_) => {
281                let map: BTreeMap<Capability, CapabilityConfig> =
282                    serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
283                Ok(MemberCapabilities::Specific(map))
284            }
285            _ => Err(serde::de::Error::custom(
286                "expected \"all\" or a map of capabilities",
287            )),
288        }
289    }
290}
291
292impl Member {
293    /// Create a member with the given capabilities and no auth fields.
294    pub fn new(capabilities: MemberCapabilities) -> Self {
295        Self {
296            capabilities,
297            public_key: None,
298            salt: None,
299            otp_hash: None,
300            ai_delegations: BTreeMap::new(),
301            attestation: None,
302        }
303    }
304
305    /// Check whether this member has a specific capability.
306    pub fn has_capability(&self, cap: &Capability) -> bool {
307        match &self.capabilities {
308            MemberCapabilities::All => true,
309            MemberCapabilities::Specific(map) => map.contains_key(cap),
310        }
311    }
312}
313
314/// Check whether a member ID represents an AI member.
315pub fn is_ai_member(id: &str) -> bool {
316    id.starts_with("ai:")
317}
318
319fn default_language() -> String {
320    "en".to_string()
321}
322
323impl Project {
324    pub fn new(name: String, acronym: Option<String>) -> Self {
325        Self {
326            name,
327            acronym,
328            description: None,
329            language: default_language(),
330            forge: None,
331            docs: Docs::default(),
332            members: BTreeMap::new(),
333            created: Utc::now(),
334        }
335    }
336}
337
338/// Validate and normalize a project acronym.
339///
340/// Acronyms drive item ID prefixes (`ACRONYM-XXXX`) and must therefore be
341/// ASCII, filesystem-safe, and short. Rules: ASCII uppercase letters (A-Z) or
342/// digits (0-9), length 2-8 after trimming. Input is trimmed and uppercased;
343/// the normalized form is returned on success so callers can store it as-is.
344pub fn validate_acronym(value: &str) -> Result<String, String> {
345    let normalized = value.trim().to_uppercase();
346    if normalized.len() < 2 || normalized.len() > 8 {
347        return Err(format!(
348            "acronym must be 2-8 characters, got {} ('{}')",
349            normalized.len(),
350            normalized
351        ));
352    }
353    for (i, c) in normalized.chars().enumerate() {
354        if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
355            return Err(format!(
356                "acronym character '{c}' at position {i} is not A-Z or 0-9"
357            ));
358        }
359    }
360    Ok(normalized)
361}
362
363/// Derive an acronym from a project name.
364/// Takes the first letter of each word, uppercase, max 4 characters.
365/// Single words use up to 3 uppercase characters.
366pub fn derive_acronym(name: &str) -> String {
367    let words: Vec<&str> = name.split_whitespace().collect();
368    if words.len() == 1 {
369        words[0]
370            .chars()
371            .filter(|c| c.is_alphanumeric())
372            .take(3)
373            .collect::<String>()
374            .to_uppercase()
375    } else {
376        words
377            .iter()
378            .filter_map(|w| w.chars().next())
379            .filter(|c| c.is_alphanumeric())
380            .take(4)
381            .collect::<String>()
382            .to_uppercase()
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn project_roundtrip() {
392        let project = Project::new("Test Project".into(), Some("TP".into()));
393        let yaml = serde_yaml_ng::to_string(&project).unwrap();
394        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
395        assert_eq!(project, parsed);
396    }
397
398    // -----------------------------------------------------------------------
399    // ai_delegations (ADR-033) tests
400    // -----------------------------------------------------------------------
401
402    #[test]
403    fn ai_delegations_omitted_when_empty() {
404        let mut m = Member::new(MemberCapabilities::All);
405        assert!(m.ai_delegations.is_empty());
406        let yaml = serde_yaml_ng::to_string(&m).unwrap();
407        assert!(
408            !yaml.contains("ai_delegations"),
409            "empty ai_delegations should be skipped, got: {yaml}"
410        );
411        // sanity: round-trips empty
412        m.public_key = Some("aa".repeat(32));
413        let yaml = serde_yaml_ng::to_string(&m).unwrap();
414        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
415        assert_eq!(m, parsed);
416    }
417
418    #[test]
419    fn ai_delegations_yaml_roundtrip() {
420        let mut m = Member::new(MemberCapabilities::All);
421        m.public_key = Some("aa".repeat(32));
422        m.salt = Some("bb".repeat(32));
423        m.ai_delegations.insert(
424            "ai:claude@joy".into(),
425            AiDelegationEntry {
426                delegation_key: "cc".repeat(32),
427                created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
428                    .unwrap()
429                    .with_timezone(&chrono::Utc),
430                rotated: None,
431            },
432        );
433        let yaml = serde_yaml_ng::to_string(&m).unwrap();
434        assert!(yaml.contains("ai_delegations:"));
435        assert!(yaml.contains("ai:claude@joy:"));
436        assert!(yaml.contains("delegation_key:"));
437        assert!(
438            !yaml.contains("rotated:"),
439            "unset rotated should be skipped"
440        );
441
442        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
443        assert_eq!(m, parsed);
444    }
445
446    #[test]
447    fn ai_delegations_with_rotated_roundtrips() {
448        let mut m = Member::new(MemberCapabilities::All);
449        let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
450            .unwrap()
451            .with_timezone(&chrono::Utc);
452        let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
453            .unwrap()
454            .with_timezone(&chrono::Utc);
455        m.ai_delegations.insert(
456            "ai:claude@joy".into(),
457            AiDelegationEntry {
458                delegation_key: "dd".repeat(32),
459                created,
460                rotated: Some(rotated),
461            },
462        );
463        let yaml = serde_yaml_ng::to_string(&m).unwrap();
464        assert!(yaml.contains("rotated:"));
465        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
466        assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
467        assert_eq!(parsed, m);
468    }
469
470    // -----------------------------------------------------------------------
471    // attestation (JOY-00FA-A5) tests
472    // -----------------------------------------------------------------------
473
474    #[test]
475    fn attestation_omitted_when_none() {
476        let m = Member::new(MemberCapabilities::All);
477        let yaml = serde_yaml_ng::to_string(&m).unwrap();
478        assert!(!yaml.contains("attestation:"));
479    }
480
481    #[test]
482    fn attestation_yaml_roundtrips() {
483        let mut m = Member::new(MemberCapabilities::All);
484        m.attestation = Some(Attestation {
485            attester: "horst@example.com".into(),
486            signed_fields: AttestationSignedFields {
487                email: "alice@example.com".into(),
488                capabilities: MemberCapabilities::All,
489                otp_hash: Some("ff".repeat(32)),
490            },
491            signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
492                .unwrap()
493                .with_timezone(&chrono::Utc),
494            signature: "aa".repeat(32),
495        });
496        let yaml = serde_yaml_ng::to_string(&m).unwrap();
497        assert!(yaml.contains("attestation:"));
498        assert!(yaml.contains("attester: horst@example.com"));
499        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
500        assert_eq!(parsed, m);
501    }
502
503    #[test]
504    fn attestation_signed_fields_canonical_is_deterministic() {
505        let a = AttestationSignedFields {
506            email: "alice@example.com".into(),
507            capabilities: MemberCapabilities::All,
508            otp_hash: Some("abc".into()),
509        };
510        let b = a.clone();
511        assert_eq!(a.canonical_bytes(), b.canonical_bytes());
512    }
513
514    #[test]
515    fn attestation_signed_fields_differ_on_capability_change() {
516        let a = AttestationSignedFields {
517            email: "alice@example.com".into(),
518            capabilities: MemberCapabilities::All,
519            otp_hash: None,
520        };
521        let mut caps = BTreeMap::new();
522        caps.insert(Capability::Implement, CapabilityConfig::default());
523        let b = AttestationSignedFields {
524            email: "alice@example.com".into(),
525            capabilities: MemberCapabilities::Specific(caps),
526            otp_hash: None,
527        };
528        assert_ne!(a.canonical_bytes(), b.canonical_bytes());
529    }
530
531    #[test]
532    fn unknown_fields_from_legacy_yaml_are_ignored() {
533        // project.yaml files written by older Joy versions may still carry
534        // ai_tokens entries. They are silently discarded by serde default
535        // behaviour and do not block deserialisation.
536        let yaml = r#"
537capabilities: all
538public_key: aa
539salt: bb
540ai_tokens:
541  ai:claude@joy:
542    token_key: oldkey
543    created: "2026-03-28T22:00:00Z"
544ai_delegations:
545  ai:claude@joy:
546    delegation_key: newkey
547    created: "2026-04-15T10:00:00Z"
548"#;
549        let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
550        assert_eq!(
551            parsed.ai_delegations["ai:claude@joy"].delegation_key,
552            "newkey"
553        );
554    }
555
556    // -----------------------------------------------------------------------
557    // Docs tests
558    // -----------------------------------------------------------------------
559
560    #[test]
561    fn docs_defaults_when_unset() {
562        let docs = Docs::default();
563        assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
564        assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
565        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
566    }
567
568    #[test]
569    fn docs_returns_configured_value() {
570        let docs = Docs {
571            architecture: Some("ARCHITECTURE.md".into()),
572            vision: Some("docs/product/vision.md".into()),
573            contributing: None,
574        };
575        assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
576        assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
577        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
578    }
579
580    #[test]
581    fn docs_omitted_from_yaml_when_empty() {
582        let project = Project::new("X".into(), None);
583        let yaml = serde_yaml_ng::to_string(&project).unwrap();
584        assert!(
585            !yaml.contains("docs:"),
586            "empty docs should be skipped, got: {yaml}"
587        );
588    }
589
590    #[test]
591    fn docs_present_in_yaml_when_set() {
592        let mut project = Project::new("X".into(), None);
593        project.docs.architecture = Some("ARCHITECTURE.md".into());
594        let yaml = serde_yaml_ng::to_string(&project).unwrap();
595        assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
596        assert!(yaml.contains("architecture: ARCHITECTURE.md"));
597        assert!(!yaml.contains("vision:"), "unset fields should be skipped");
598    }
599
600    #[test]
601    fn docs_yaml_roundtrip_with_overrides() {
602        let yaml = r#"
603name: Existing
604language: en
605docs:
606  architecture: ARCHITECTURE.md
607  contributing: docs/CONTRIBUTING.md
608created: 2026-01-01T00:00:00Z
609"#;
610        let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
611        assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
612        assert_eq!(parsed.docs.vision, None);
613        assert_eq!(
614            parsed.docs.contributing.as_deref(),
615            Some("docs/CONTRIBUTING.md")
616        );
617        assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
618    }
619
620    #[test]
621    fn derive_acronym_multi_word() {
622        assert_eq!(derive_acronym("My Cool Project"), "MCP");
623    }
624
625    #[test]
626    fn derive_acronym_single_word() {
627        assert_eq!(derive_acronym("Joy"), "JOY");
628    }
629
630    #[test]
631    fn derive_acronym_long_name() {
632        assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
633    }
634
635    #[test]
636    fn derive_acronym_single_long_word() {
637        assert_eq!(derive_acronym("Platform"), "PLA");
638    }
639
640    // -----------------------------------------------------------------------
641    // validate_acronym tests
642    // -----------------------------------------------------------------------
643
644    #[test]
645    fn validate_acronym_accepts_real_project_acronyms() {
646        for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
647            assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
648        }
649    }
650
651    #[test]
652    fn validate_acronym_accepts_alphanumeric() {
653        assert_eq!(validate_acronym("V2").unwrap(), "V2");
654        assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
655    }
656
657    #[test]
658    fn validate_acronym_normalizes_case_and_whitespace() {
659        assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
660        assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
661        assert_eq!(validate_acronym("  jyn  ").unwrap(), "JYN");
662    }
663
664    #[test]
665    fn validate_acronym_rejects_too_short() {
666        assert!(validate_acronym("").is_err());
667        assert!(validate_acronym("J").is_err());
668        assert!(validate_acronym(" J ").is_err());
669    }
670
671    #[test]
672    fn validate_acronym_rejects_too_long() {
673        assert!(validate_acronym("ABCDEFGHI").is_err());
674    }
675
676    #[test]
677    fn validate_acronym_rejects_non_alnum() {
678        assert!(validate_acronym("JY-N").is_err());
679        assert!(validate_acronym("JY N").is_err());
680        assert!(validate_acronym("JY_N").is_err());
681        assert!(validate_acronym("JY.N").is_err());
682    }
683
684    #[test]
685    fn validate_acronym_rejects_non_ascii() {
686        assert!(validate_acronym("AEBC").is_ok());
687        assert!(validate_acronym("ABC").is_ok());
688        assert!(validate_acronym("\u{00c4}BC").is_err());
689    }
690
691    // -----------------------------------------------------------------------
692    // ModeDefaults deserialization tests
693    // -----------------------------------------------------------------------
694
695    #[test]
696    fn mode_defaults_flat_yaml_roundtrip() {
697        let yaml = r#"
698default: interactive
699implement: collaborative
700review: pairing
701"#;
702        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
703        assert_eq!(parsed.default, InteractionLevel::Interactive);
704        assert_eq!(
705            parsed.capabilities[&Capability::Implement],
706            InteractionLevel::Collaborative
707        );
708        assert_eq!(
709            parsed.capabilities[&Capability::Review],
710            InteractionLevel::Pairing
711        );
712    }
713
714    #[test]
715    fn mode_defaults_empty_yaml() {
716        let yaml = "{}";
717        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
718        assert_eq!(parsed.default, InteractionLevel::Collaborative);
719        assert!(parsed.capabilities.is_empty());
720    }
721
722    #[test]
723    fn mode_defaults_only_default() {
724        let yaml = "default: pairing";
725        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
726        assert_eq!(parsed.default, InteractionLevel::Pairing);
727        assert!(parsed.capabilities.is_empty());
728    }
729
730    #[test]
731    fn ai_defaults_yaml_roundtrip() {
732        let yaml = r#"
733capabilities:
734  - implement
735  - review
736"#;
737        let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
738        assert_eq!(parsed.capabilities.len(), 2);
739        assert_eq!(parsed.capabilities[0], Capability::Implement);
740    }
741
742    // -----------------------------------------------------------------------
743    // resolve_mode tests
744    // -----------------------------------------------------------------------
745
746    fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
747        ModeDefaults {
748            default: mode,
749            ..Default::default()
750        }
751    }
752
753    fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
754        let mut d = ModeDefaults::default();
755        d.capabilities.insert(cap, mode);
756        d
757    }
758
759    #[test]
760    fn resolve_mode_uses_global_default() {
761        let raw = defaults_with_mode(InteractionLevel::Collaborative);
762        let effective = raw.clone();
763        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
764        assert_eq!(mode, InteractionLevel::Collaborative);
765        assert_eq!(source, ModeSource::Default);
766    }
767
768    #[test]
769    fn resolve_mode_uses_per_capability_default() {
770        let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
771        let effective = raw.clone();
772        let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
773        assert_eq!(mode, InteractionLevel::Interactive);
774        assert_eq!(source, ModeSource::Default);
775    }
776
777    #[test]
778    fn resolve_mode_project_override_detected() {
779        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
780        let effective =
781            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
782        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
783        assert_eq!(mode, InteractionLevel::Interactive);
784        assert_eq!(source, ModeSource::Project);
785    }
786
787    #[test]
788    fn resolve_mode_personal_overrides_default() {
789        let raw = defaults_with_mode(InteractionLevel::Collaborative);
790        let effective = raw.clone();
791        let (mode, source) = resolve_mode(
792            &Capability::Implement,
793            &raw,
794            &effective,
795            Some(InteractionLevel::Pairing),
796            None,
797        );
798        assert_eq!(mode, InteractionLevel::Pairing);
799        assert_eq!(source, ModeSource::Personal);
800    }
801
802    #[test]
803    fn resolve_mode_max_mode_clamps_upward() {
804        let raw = defaults_with_mode(InteractionLevel::Autonomous);
805        let effective = raw.clone();
806        let cap_config = CapabilityConfig {
807            max_mode: Some(InteractionLevel::Supervised),
808            ..Default::default()
809        };
810        let (mode, source) = resolve_mode(
811            &Capability::Implement,
812            &raw,
813            &effective,
814            None,
815            Some(&cap_config),
816        );
817        assert_eq!(mode, InteractionLevel::Supervised);
818        assert_eq!(source, ModeSource::ProjectMax);
819    }
820
821    #[test]
822    fn resolve_mode_max_mode_does_not_lower() {
823        let raw = defaults_with_mode(InteractionLevel::Pairing);
824        let effective = raw.clone();
825        let cap_config = CapabilityConfig {
826            max_mode: Some(InteractionLevel::Supervised),
827            ..Default::default()
828        };
829        let (mode, source) = resolve_mode(
830            &Capability::Implement,
831            &raw,
832            &effective,
833            None,
834            Some(&cap_config),
835        );
836        // Pairing > Supervised, so no clamping
837        assert_eq!(mode, InteractionLevel::Pairing);
838        assert_eq!(source, ModeSource::Default);
839    }
840
841    #[test]
842    fn resolve_mode_personal_clamped_by_max() {
843        let raw = defaults_with_mode(InteractionLevel::Collaborative);
844        let effective = raw.clone();
845        let cap_config = CapabilityConfig {
846            max_mode: Some(InteractionLevel::Interactive),
847            ..Default::default()
848        };
849        let (mode, source) = resolve_mode(
850            &Capability::Implement,
851            &raw,
852            &effective,
853            Some(InteractionLevel::Autonomous),
854            Some(&cap_config),
855        );
856        // Personal is Autonomous but max is Interactive, clamp up
857        assert_eq!(mode, InteractionLevel::Interactive);
858        assert_eq!(source, ModeSource::ProjectMax);
859    }
860
861    // -----------------------------------------------------------------------
862    // Item mode serialization
863    // -----------------------------------------------------------------------
864
865    #[test]
866    fn item_mode_field_roundtrip() {
867        use crate::model::item::{Item, ItemType, Priority};
868
869        let mut item = Item::new(
870            "TST-0001".into(),
871            "Test".into(),
872            ItemType::Task,
873            Priority::Medium,
874            vec![],
875        );
876        item.mode = Some(InteractionLevel::Pairing);
877
878        let yaml = serde_yaml_ng::to_string(&item).unwrap();
879        assert!(yaml.contains("mode: pairing"), "mode field not serialized");
880
881        let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
882        assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
883    }
884
885    #[test]
886    fn item_mode_field_absent_when_none() {
887        use crate::model::item::{Item, ItemType, Priority};
888
889        let item = Item::new(
890            "TST-0002".into(),
891            "Test".into(),
892            ItemType::Task,
893            Priority::Medium,
894            vec![],
895        );
896        assert_eq!(item.mode, None);
897
898        let yaml = serde_yaml_ng::to_string(&item).unwrap();
899        assert!(
900            !yaml.contains("mode:"),
901            "mode field should not appear when None"
902        );
903    }
904
905    #[test]
906    fn item_mode_deserialized_from_existing_yaml() {
907        let yaml = r#"
908id: TST-0003
909title: Test
910type: task
911status: new
912priority: medium
913mode: interactive
914created: "2026-01-01T00:00:00+00:00"
915updated: "2026-01-01T00:00:00+00:00"
916"#;
917        let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
918        assert_eq!(item.mode, Some(InteractionLevel::Interactive));
919    }
920
921    // -----------------------------------------------------------------------
922    // Full four-layer resolution scenario
923    // -----------------------------------------------------------------------
924
925    #[test]
926    fn resolve_mode_full_scenario() {
927        // Joy default: implement = collaborative
928        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
929        // Project override: implement = interactive
930        let effective =
931            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
932        // Personal preference: autonomous
933        let personal = Some(InteractionLevel::Autonomous);
934        // Project max-mode: supervised (minimum interactivity)
935        let cap_config = CapabilityConfig {
936            max_mode: Some(InteractionLevel::Supervised),
937            ..Default::default()
938        };
939
940        let (mode, source) = resolve_mode(
941            &Capability::Implement,
942            &raw,
943            &effective,
944            personal,
945            Some(&cap_config),
946        );
947
948        // Personal (autonomous) < max (supervised), so clamped up to supervised
949        assert_eq!(mode, InteractionLevel::Supervised);
950        assert_eq!(source, ModeSource::ProjectMax);
951    }
952
953    #[test]
954    fn resolve_mode_all_layers_no_clamping() {
955        // Joy default: implement = collaborative
956        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
957        // Project override: implement = interactive
958        let effective =
959            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
960        // Personal preference: pairing (more interactive than project)
961        let personal = Some(InteractionLevel::Pairing);
962        // No max-mode
963        let cap_config = CapabilityConfig::default();
964
965        let (mode, source) = resolve_mode(
966            &Capability::Implement,
967            &raw,
968            &effective,
969            personal,
970            Some(&cap_config),
971        );
972
973        // Personal wins, no clamping
974        assert_eq!(mode, InteractionLevel::Pairing);
975        assert_eq!(source, ModeSource::Personal);
976    }
977}