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        "forge" => {
422            "release forge override (e.g. github, none); unset = auto-detect from git remotes"
423        }
424        "release.version-files" => {
425            "paths whose version strings `joy release bump` rewrites; managed with `joy project set release.version-files --add/--rm/<csv>`"
426        }
427        "created" => "ISO timestamp when the project was initialized",
428        "docs.architecture" => "path to the technical architecture document",
429        "docs.vision" => "path to the product-vision document",
430        "docs.contributing" => "path to the contributing guide",
431        _ => return None,
432    };
433    Some(text.to_string())
434}
435
436fn default_language() -> String {
437    "en".to_string()
438}
439
440impl Project {
441    pub fn new(name: String, acronym: Option<String>) -> Self {
442        Self {
443            name,
444            acronym,
445            description: None,
446            language: default_language(),
447            forge: None,
448            docs: Docs::default(),
449            members: BTreeMap::new(),
450            crypt: CryptConfig::default(),
451            created: Utc::now(),
452        }
453    }
454}
455
456/// Validate and normalize a project acronym.
457///
458/// Acronyms drive item ID prefixes (`ACRONYM-XXXX`) and must therefore be
459/// ASCII, filesystem-safe, and short. Rules: ASCII uppercase letters (A-Z) or
460/// digits (0-9), length 2-8 after trimming. Input is trimmed and uppercased;
461/// the normalized form is returned on success so callers can store it as-is.
462pub fn validate_acronym(value: &str) -> Result<String, String> {
463    let normalized = value.trim().to_uppercase();
464    if normalized.len() < 2 || normalized.len() > 8 {
465        return Err(format!(
466            "acronym must be 2-8 characters, got {} ('{}')",
467            normalized.len(),
468            normalized
469        ));
470    }
471    for (i, c) in normalized.chars().enumerate() {
472        if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
473            return Err(format!(
474                "acronym character '{c}' at position {i} is not A-Z or 0-9"
475            ));
476        }
477    }
478    Ok(normalized)
479}
480
481/// Derive an acronym from a project name.
482/// Takes the first letter of each word, uppercase, max 4 characters.
483/// Single words use up to 3 uppercase characters.
484pub fn derive_acronym(name: &str) -> String {
485    let words: Vec<&str> = name.split_whitespace().collect();
486    if words.len() == 1 {
487        words[0]
488            .chars()
489            .filter(|c| c.is_alphanumeric())
490            .take(3)
491            .collect::<String>()
492            .to_uppercase()
493    } else {
494        words
495            .iter()
496            .filter_map(|w| w.chars().next())
497            .filter(|c| c.is_alphanumeric())
498            .take(4)
499            .collect::<String>()
500            .to_uppercase()
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn project_roundtrip() {
510        let project = Project::new("Test Project".into(), Some("TP".into()));
511        let yaml = serde_yaml_ng::to_string(&project).unwrap();
512        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
513        assert_eq!(project, parsed);
514    }
515
516    #[test]
517    fn describe_value_covers_documented_keys() {
518        let dummy = serde_json::Value::Null;
519        for key in &[
520            "name",
521            "acronym",
522            "description",
523            "language",
524            "forge",
525            "release.version-files",
526            "created",
527            "docs.architecture",
528            "docs.vision",
529            "docs.contributing",
530        ] {
531            assert!(
532                describe_value(key, &dummy).is_some(),
533                "missing description for project key {key}"
534            );
535        }
536        assert!(describe_value("unknown", &dummy).is_none());
537    }
538
539    // -----------------------------------------------------------------------
540    // ai_delegations (ADR-033) tests
541    // -----------------------------------------------------------------------
542
543    #[test]
544    fn ai_delegations_omitted_when_empty() {
545        let mut m = Member::new(MemberCapabilities::All);
546        assert!(m.ai_delegations.is_empty());
547        let yaml = serde_yaml_ng::to_string(&m).unwrap();
548        assert!(
549            !yaml.contains("ai_delegations"),
550            "empty ai_delegations should be skipped, got: {yaml}"
551        );
552        // sanity: round-trips empty
553        m.verify_key = Some("aa".repeat(32));
554        let yaml = serde_yaml_ng::to_string(&m).unwrap();
555        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
556        assert_eq!(m, parsed);
557    }
558
559    #[test]
560    fn ai_delegations_yaml_roundtrip() {
561        let mut m = Member::new(MemberCapabilities::All);
562        m.verify_key = Some("aa".repeat(32));
563        m.kdf_nonce = Some("bb".repeat(32));
564        m.ai_delegations.insert(
565            "ai:claude@joy".into(),
566            AiDelegationEntry {
567                delegation_verifier: "cc".repeat(32),
568                delegation_salt: None,
569                created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
570                    .unwrap()
571                    .with_timezone(&chrono::Utc),
572                rotated: None,
573            },
574        );
575        let yaml = serde_yaml_ng::to_string(&m).unwrap();
576        assert!(yaml.contains("ai_delegations:"));
577        assert!(yaml.contains("ai:claude@joy:"));
578        assert!(yaml.contains("delegation_verifier:"));
579        assert!(
580            !yaml.contains("delegation_salt:"),
581            "unset delegation_salt should be skipped (legacy entry)"
582        );
583        assert!(
584            !yaml.contains("rotated:"),
585            "unset rotated should be skipped"
586        );
587
588        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
589        assert_eq!(m, parsed);
590    }
591
592    #[test]
593    fn ai_delegations_with_rotated_roundtrips() {
594        let mut m = Member::new(MemberCapabilities::All);
595        let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
596            .unwrap()
597            .with_timezone(&chrono::Utc);
598        let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
599            .unwrap()
600            .with_timezone(&chrono::Utc);
601        m.ai_delegations.insert(
602            "ai:claude@joy".into(),
603            AiDelegationEntry {
604                delegation_verifier: "dd".repeat(32),
605                delegation_salt: None,
606                created,
607                rotated: Some(rotated),
608            },
609        );
610        let yaml = serde_yaml_ng::to_string(&m).unwrap();
611        assert!(yaml.contains("rotated:"));
612        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
613        assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
614        assert_eq!(parsed, m);
615    }
616
617    // -----------------------------------------------------------------------
618    // attestation (JOY-00FA-A5) tests
619    // -----------------------------------------------------------------------
620
621    #[test]
622    fn attestation_omitted_when_none() {
623        let m = Member::new(MemberCapabilities::All);
624        let yaml = serde_yaml_ng::to_string(&m).unwrap();
625        assert!(!yaml.contains("attestation:"));
626    }
627
628    #[test]
629    fn attestation_yaml_roundtrips() {
630        let mut m = Member::new(MemberCapabilities::All);
631        m.attestation = Some(Attestation {
632            attester: "horst@example.com".into(),
633            signed_fields: AttestationSignedFields {
634                email: "alice@example.com".into(),
635                capabilities: MemberCapabilities::All,
636                enrollment_verifier: Some("ff".repeat(32)),
637            },
638            signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
639                .unwrap()
640                .with_timezone(&chrono::Utc),
641            signature: "aa".repeat(32),
642        });
643        let yaml = serde_yaml_ng::to_string(&m).unwrap();
644        assert!(yaml.contains("attestation:"));
645        assert!(yaml.contains("attester: horst@example.com"));
646        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
647        assert_eq!(parsed, m);
648    }
649
650    #[test]
651    fn attestation_signed_fields_canonical_is_deterministic() {
652        let a = AttestationSignedFields {
653            email: "alice@example.com".into(),
654            capabilities: MemberCapabilities::All,
655            enrollment_verifier: Some("abc".into()),
656        };
657        let b = a.clone();
658        assert_eq!(a.canonical_bytes(), b.canonical_bytes());
659    }
660
661    #[test]
662    fn attestation_signed_fields_differ_on_capability_change() {
663        let a = AttestationSignedFields {
664            email: "alice@example.com".into(),
665            capabilities: MemberCapabilities::All,
666            enrollment_verifier: None,
667        };
668        let mut caps = BTreeMap::new();
669        caps.insert(Capability::Implement, CapabilityConfig::default());
670        let b = AttestationSignedFields {
671            email: "alice@example.com".into(),
672            capabilities: MemberCapabilities::Specific(caps),
673            enrollment_verifier: None,
674        };
675        assert_ne!(a.canonical_bytes(), b.canonical_bytes());
676    }
677
678    #[test]
679    fn unknown_fields_from_legacy_yaml_are_ignored() {
680        // project.yaml files written by older Joy versions may still carry
681        // ai_tokens entries. They are silently discarded by serde default
682        // behaviour and do not block deserialisation.
683        let yaml = r#"
684capabilities: all
685public_key: aa
686salt: bb
687ai_tokens:
688  ai:claude@joy:
689    token_key: oldkey
690    created: "2026-03-28T22:00:00Z"
691ai_delegations:
692  ai:claude@joy:
693    delegation_verifier: newkey
694    created: "2026-04-15T10:00:00Z"
695"#;
696        let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
697        assert_eq!(
698            parsed.ai_delegations["ai:claude@joy"].delegation_verifier,
699            "newkey"
700        );
701    }
702
703    // -----------------------------------------------------------------------
704    // Docs tests
705    // -----------------------------------------------------------------------
706
707    #[test]
708    fn docs_defaults_when_unset() {
709        let docs = Docs::default();
710        assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
711        assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
712        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
713    }
714
715    #[test]
716    fn docs_returns_configured_value() {
717        let docs = Docs {
718            architecture: Some("ARCHITECTURE.md".into()),
719            vision: Some("docs/product/vision.md".into()),
720            contributing: None,
721        };
722        assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
723        assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
724        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
725    }
726
727    #[test]
728    fn docs_omitted_from_yaml_when_empty() {
729        let project = Project::new("X".into(), None);
730        let yaml = serde_yaml_ng::to_string(&project).unwrap();
731        assert!(
732            !yaml.contains("docs:"),
733            "empty docs should be skipped, got: {yaml}"
734        );
735    }
736
737    #[test]
738    fn docs_present_in_yaml_when_set() {
739        let mut project = Project::new("X".into(), None);
740        project.docs.architecture = Some("ARCHITECTURE.md".into());
741        let yaml = serde_yaml_ng::to_string(&project).unwrap();
742        assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
743        assert!(yaml.contains("architecture: ARCHITECTURE.md"));
744        assert!(!yaml.contains("vision:"), "unset fields should be skipped");
745    }
746
747    #[test]
748    fn docs_yaml_roundtrip_with_overrides() {
749        let yaml = r#"
750name: Existing
751language: en
752docs:
753  architecture: ARCHITECTURE.md
754  contributing: docs/CONTRIBUTING.md
755created: 2026-01-01T00:00:00Z
756"#;
757        let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
758        assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
759        assert_eq!(parsed.docs.vision, None);
760        assert_eq!(
761            parsed.docs.contributing.as_deref(),
762            Some("docs/CONTRIBUTING.md")
763        );
764        assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
765    }
766
767    #[test]
768    fn derive_acronym_multi_word() {
769        assert_eq!(derive_acronym("My Cool Project"), "MCP");
770    }
771
772    #[test]
773    fn derive_acronym_single_word() {
774        assert_eq!(derive_acronym("Joy"), "JOY");
775    }
776
777    #[test]
778    fn derive_acronym_long_name() {
779        assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
780    }
781
782    #[test]
783    fn derive_acronym_single_long_word() {
784        assert_eq!(derive_acronym("Platform"), "PLA");
785    }
786
787    // -----------------------------------------------------------------------
788    // validate_acronym tests
789    // -----------------------------------------------------------------------
790
791    #[test]
792    fn validate_acronym_accepts_real_project_acronyms() {
793        for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
794            assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
795        }
796    }
797
798    #[test]
799    fn validate_acronym_accepts_alphanumeric() {
800        assert_eq!(validate_acronym("V2").unwrap(), "V2");
801        assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
802    }
803
804    #[test]
805    fn validate_acronym_normalizes_case_and_whitespace() {
806        assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
807        assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
808        assert_eq!(validate_acronym("  jyn  ").unwrap(), "JYN");
809    }
810
811    #[test]
812    fn validate_acronym_rejects_too_short() {
813        assert!(validate_acronym("").is_err());
814        assert!(validate_acronym("J").is_err());
815        assert!(validate_acronym(" J ").is_err());
816    }
817
818    #[test]
819    fn validate_acronym_rejects_too_long() {
820        assert!(validate_acronym("ABCDEFGHI").is_err());
821    }
822
823    #[test]
824    fn validate_acronym_rejects_non_alnum() {
825        assert!(validate_acronym("JY-N").is_err());
826        assert!(validate_acronym("JY N").is_err());
827        assert!(validate_acronym("JY_N").is_err());
828        assert!(validate_acronym("JY.N").is_err());
829    }
830
831    #[test]
832    fn validate_acronym_rejects_non_ascii() {
833        assert!(validate_acronym("AEBC").is_ok());
834        assert!(validate_acronym("ABC").is_ok());
835        assert!(validate_acronym("\u{00c4}BC").is_err());
836    }
837
838    // -----------------------------------------------------------------------
839    // ModeDefaults deserialization tests
840    // -----------------------------------------------------------------------
841
842    #[test]
843    fn mode_defaults_flat_yaml_roundtrip() {
844        let yaml = r#"
845default: interactive
846implement: collaborative
847review: pairing
848"#;
849        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
850        assert_eq!(parsed.default, InteractionLevel::Interactive);
851        assert_eq!(
852            parsed.capabilities[&Capability::Implement],
853            InteractionLevel::Collaborative
854        );
855        assert_eq!(
856            parsed.capabilities[&Capability::Review],
857            InteractionLevel::Pairing
858        );
859    }
860
861    #[test]
862    fn mode_defaults_empty_yaml() {
863        let yaml = "{}";
864        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
865        assert_eq!(parsed.default, InteractionLevel::Collaborative);
866        assert!(parsed.capabilities.is_empty());
867    }
868
869    #[test]
870    fn mode_defaults_only_default() {
871        let yaml = "default: pairing";
872        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
873        assert_eq!(parsed.default, InteractionLevel::Pairing);
874        assert!(parsed.capabilities.is_empty());
875    }
876
877    #[test]
878    fn ai_defaults_yaml_roundtrip() {
879        let yaml = r#"
880capabilities:
881  - implement
882  - review
883"#;
884        let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
885        assert_eq!(parsed.capabilities.len(), 2);
886        assert_eq!(parsed.capabilities[0], Capability::Implement);
887    }
888
889    // -----------------------------------------------------------------------
890    // resolve_mode tests
891    // -----------------------------------------------------------------------
892
893    fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
894        ModeDefaults {
895            default: mode,
896            ..Default::default()
897        }
898    }
899
900    fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
901        let mut d = ModeDefaults::default();
902        d.capabilities.insert(cap, mode);
903        d
904    }
905
906    #[test]
907    fn resolve_mode_uses_global_default() {
908        let raw = defaults_with_mode(InteractionLevel::Collaborative);
909        let effective = raw.clone();
910        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
911        assert_eq!(mode, InteractionLevel::Collaborative);
912        assert_eq!(source, ModeSource::Default);
913    }
914
915    #[test]
916    fn resolve_mode_uses_per_capability_default() {
917        let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
918        let effective = raw.clone();
919        let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
920        assert_eq!(mode, InteractionLevel::Interactive);
921        assert_eq!(source, ModeSource::Default);
922    }
923
924    #[test]
925    fn resolve_mode_project_override_detected() {
926        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
927        let effective =
928            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
929        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
930        assert_eq!(mode, InteractionLevel::Interactive);
931        assert_eq!(source, ModeSource::Project);
932    }
933
934    #[test]
935    fn resolve_mode_personal_overrides_default() {
936        let raw = defaults_with_mode(InteractionLevel::Collaborative);
937        let effective = raw.clone();
938        let (mode, source) = resolve_mode(
939            &Capability::Implement,
940            &raw,
941            &effective,
942            Some(InteractionLevel::Pairing),
943            None,
944        );
945        assert_eq!(mode, InteractionLevel::Pairing);
946        assert_eq!(source, ModeSource::Personal);
947    }
948
949    #[test]
950    fn resolve_mode_max_mode_clamps_upward() {
951        let raw = defaults_with_mode(InteractionLevel::Autonomous);
952        let effective = raw.clone();
953        let cap_config = CapabilityConfig {
954            max_mode: Some(InteractionLevel::Supervised),
955            ..Default::default()
956        };
957        let (mode, source) = resolve_mode(
958            &Capability::Implement,
959            &raw,
960            &effective,
961            None,
962            Some(&cap_config),
963        );
964        assert_eq!(mode, InteractionLevel::Supervised);
965        assert_eq!(source, ModeSource::ProjectMax);
966    }
967
968    #[test]
969    fn resolve_mode_max_mode_does_not_lower() {
970        let raw = defaults_with_mode(InteractionLevel::Pairing);
971        let effective = raw.clone();
972        let cap_config = CapabilityConfig {
973            max_mode: Some(InteractionLevel::Supervised),
974            ..Default::default()
975        };
976        let (mode, source) = resolve_mode(
977            &Capability::Implement,
978            &raw,
979            &effective,
980            None,
981            Some(&cap_config),
982        );
983        // Pairing > Supervised, so no clamping
984        assert_eq!(mode, InteractionLevel::Pairing);
985        assert_eq!(source, ModeSource::Default);
986    }
987
988    #[test]
989    fn resolve_mode_personal_clamped_by_max() {
990        let raw = defaults_with_mode(InteractionLevel::Collaborative);
991        let effective = raw.clone();
992        let cap_config = CapabilityConfig {
993            max_mode: Some(InteractionLevel::Interactive),
994            ..Default::default()
995        };
996        let (mode, source) = resolve_mode(
997            &Capability::Implement,
998            &raw,
999            &effective,
1000            Some(InteractionLevel::Autonomous),
1001            Some(&cap_config),
1002        );
1003        // Personal is Autonomous but max is Interactive, clamp up
1004        assert_eq!(mode, InteractionLevel::Interactive);
1005        assert_eq!(source, ModeSource::ProjectMax);
1006    }
1007
1008    // -----------------------------------------------------------------------
1009    // Item mode serialization
1010    // -----------------------------------------------------------------------
1011
1012    #[test]
1013    fn item_mode_field_roundtrip() {
1014        use crate::model::item::{Item, ItemType, Priority};
1015
1016        let mut item = Item::new(
1017            "TST-0001".into(),
1018            "Test".into(),
1019            ItemType::Task,
1020            Priority::Medium,
1021            vec![],
1022        );
1023        item.mode = Some(InteractionLevel::Pairing);
1024
1025        let yaml = serde_yaml_ng::to_string(&item).unwrap();
1026        assert!(yaml.contains("mode: pairing"), "mode field not serialized");
1027
1028        let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
1029        assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
1030    }
1031
1032    #[test]
1033    fn item_mode_field_absent_when_none() {
1034        use crate::model::item::{Item, ItemType, Priority};
1035
1036        let item = Item::new(
1037            "TST-0002".into(),
1038            "Test".into(),
1039            ItemType::Task,
1040            Priority::Medium,
1041            vec![],
1042        );
1043        assert_eq!(item.mode, None);
1044
1045        let yaml = serde_yaml_ng::to_string(&item).unwrap();
1046        assert!(
1047            !yaml.contains("mode:"),
1048            "mode field should not appear when None"
1049        );
1050    }
1051
1052    #[test]
1053    fn item_mode_deserialized_from_existing_yaml() {
1054        let yaml = r#"
1055id: TST-0003
1056title: Test
1057type: task
1058status: new
1059priority: medium
1060mode: interactive
1061created: "2026-01-01T00:00:00+00:00"
1062updated: "2026-01-01T00:00:00+00:00"
1063"#;
1064        let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
1065        assert_eq!(item.mode, Some(InteractionLevel::Interactive));
1066    }
1067
1068    // -----------------------------------------------------------------------
1069    // Full four-layer resolution scenario
1070    // -----------------------------------------------------------------------
1071
1072    #[test]
1073    fn resolve_mode_full_scenario() {
1074        // Joy default: implement = collaborative
1075        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1076        // Project override: implement = interactive
1077        let effective =
1078            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1079        // Personal preference: autonomous
1080        let personal = Some(InteractionLevel::Autonomous);
1081        // Project max-mode: supervised (minimum interactivity)
1082        let cap_config = CapabilityConfig {
1083            max_mode: Some(InteractionLevel::Supervised),
1084            ..Default::default()
1085        };
1086
1087        let (mode, source) = resolve_mode(
1088            &Capability::Implement,
1089            &raw,
1090            &effective,
1091            personal,
1092            Some(&cap_config),
1093        );
1094
1095        // Personal (autonomous) < max (supervised), so clamped up to supervised
1096        assert_eq!(mode, InteractionLevel::Supervised);
1097        assert_eq!(source, ModeSource::ProjectMax);
1098    }
1099
1100    #[test]
1101    fn resolve_mode_all_layers_no_clamping() {
1102        // Joy default: implement = collaborative
1103        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1104        // Project override: implement = interactive
1105        let effective =
1106            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1107        // Personal preference: pairing (more interactive than project)
1108        let personal = Some(InteractionLevel::Pairing);
1109        // No max-mode
1110        let cap_config = CapabilityConfig::default();
1111
1112        let (mode, source) = resolve_mode(
1113            &Capability::Implement,
1114            &raw,
1115            &effective,
1116            personal,
1117            Some(&cap_config),
1118        );
1119
1120        // Personal wins, no clamping
1121        assert_eq!(mode, InteractionLevel::Pairing);
1122        assert_eq!(source, ModeSource::Personal);
1123    }
1124}