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