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