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