Skip to main content

nodex_core/
lifecycle.rs

1use chrono::Local;
2use std::path::Path;
3
4use crate::config::Config;
5use crate::error::{Error, Result};
6
7/// Canonical status values produced by each non-review lifecycle action.
8///
9/// These are part of the tool's operational contract — `lifecycle
10/// archive` means exactly "set status to archived". Projects may add
11/// extra statuses to `statuses.allowed`, but must keep these four so
12/// lifecycle-written documents pass `status` enum validation.
13/// `Config::validate` enforces the coverage at load time.
14pub const SUPERSEDED: &str = "superseded";
15pub const ARCHIVED: &str = "archived";
16pub const DEPRECATED: &str = "deprecated";
17pub const ABANDONED: &str = "abandoned";
18
19/// All status values the lifecycle command can write.
20/// Used by `Config::validate` to enforce vocabulary coverage.
21pub const LIFECYCLE_TARGET_STATUSES: &[&str] = &[SUPERSEDED, ARCHIVED, DEPRECATED, ABANDONED];
22
23/// Lifecycle action.
24///
25/// Variants that need additional data (a successor id for `Supersede`)
26/// carry it in-line so callers cannot invoke `transition()` with the
27/// wrong combination of fields. The CLI layer and any library consumer
28/// are structurally forced to supply the successor when — and only
29/// when — they intend to supersede.
30#[derive(Debug, Clone)]
31pub enum Action<'a> {
32    Supersede { successor: &'a str },
33    Archive,
34    Deprecate,
35    Abandon,
36    Review,
37}
38
39impl Action<'_> {
40    /// Target status written to the document, or `None` for review
41    /// (which only touches the `reviewed` date).
42    pub fn target_status(&self) -> Option<&'static str> {
43        match self {
44            Self::Supersede { .. } => Some(SUPERSEDED),
45            Self::Archive => Some(ARCHIVED),
46            Self::Deprecate => Some(DEPRECATED),
47            Self::Abandon => Some(ABANDONED),
48            Self::Review => None,
49        }
50    }
51
52    /// String rendering of an action, exposed for logging / JSON output.
53    pub fn name(&self) -> &'static str {
54        match self {
55            Self::Supersede { .. } => "supersede",
56            Self::Archive => "archive",
57            Self::Deprecate => "deprecate",
58            Self::Abandon => "abandon",
59            Self::Review => "review",
60        }
61    }
62}
63
64/// Apply a lifecycle transition to a document file.
65/// Returns the updated file content.
66pub fn transition(
67    root: &Path,
68    rel_path: &Path,
69    action: Action<'_>,
70    config: &Config,
71) -> Result<String> {
72    let abs_path = root.join(rel_path);
73
74    // Refuse to mutate through a symlink. The scanner follows symlinks
75    // on read (so `build` still indexes linked files), but writing
76    // through a symlink here would let `nodex lifecycle <action>
77    // some-id` modify files outside the project root — audit #5
78    // already closed this hole for `migrate`, which skips symlinks.
79    // Lifecycle needs the same guard. Use `PathEscapesRoot` to route
80    // through the classifier as `PATH_ESCAPES_ROOT` (exit 2).
81    if crate::path_guard::is_symlink(&abs_path) {
82        return Err(Error::PathEscapesRoot {
83            path: rel_path.to_path_buf(),
84        });
85    }
86
87    let content = std::fs::read_to_string(&abs_path).map_err(|e| Error::Io {
88        path: abs_path.clone(),
89        source: e,
90    })?;
91
92    let (yaml_opt, body) = crate::parser::frontmatter::split_frontmatter(&content);
93    let Some(yaml_str) = yaml_opt else {
94        return Err(Error::Frontmatter {
95            path: abs_path,
96            message: "no frontmatter found".to_string(),
97        });
98    };
99
100    let mut fm: yaml_serde::Value = yaml_serde::from_str(yaml_str).map_err(|e| Error::Yaml {
101        path: abs_path.clone(),
102        source: e,
103    })?;
104
105    let mapping = fm.as_mapping_mut().ok_or_else(|| Error::Frontmatter {
106        path: abs_path.clone(),
107        message: "frontmatter is not a YAML mapping".to_string(),
108    })?;
109
110    // Validate current status. A missing or non-string status field
111    // is treated as non-terminal — any project-specific vocabulary
112    // check happens later in `nodex check`, so we don't block the
113    // transition on it here and don't embed a hardcoded "active"
114    // sentinel that would couple this path to one particular config.
115    let current_status = mapping
116        .get(yaml_serde::Value::String("status".to_string()))
117        .and_then(|v| v.as_str())
118        .unwrap_or("")
119        .to_string();
120
121    if config.is_terminal(&current_status) && !matches!(action, Action::Review) {
122        // The `!Review` guard above means `target_status()` is `Some`.
123        let to = action
124            .target_status()
125            .expect("non-Review action always has a target status");
126        return Err(Error::InvalidTransition {
127            node_id: rel_path.to_string_lossy().to_string(),
128            from: current_status,
129            to: to.to_string(),
130        });
131    }
132
133    let today = Local::now().date_naive().to_string();
134
135    match action {
136        Action::Supersede { successor } => {
137            set_field(mapping, "status", SUPERSEDED);
138            set_field(mapping, "superseded_by", successor);
139            set_field(mapping, "updated", &today);
140        }
141        Action::Archive => {
142            set_field(mapping, "status", ARCHIVED);
143            set_field(mapping, "updated", &today);
144        }
145        Action::Deprecate => {
146            set_field(mapping, "status", DEPRECATED);
147            set_field(mapping, "updated", &today);
148        }
149        Action::Abandon => {
150            set_field(mapping, "status", ABANDONED);
151            set_field(mapping, "updated", &today);
152        }
153        Action::Review => {
154            set_field(mapping, "reviewed", &today);
155        }
156    }
157
158    // Reconstruct file
159    let new_yaml = yaml_serde::to_string(&fm)
160        .map_err(|e| Error::Other(format!("YAML serialization error: {e}")))?;
161
162    let new_content = format!("---\n{new_yaml}---\n{body}");
163
164    std::fs::write(&abs_path, &new_content).map_err(|e| Error::Io {
165        path: abs_path,
166        source: e,
167    })?;
168
169    Ok(new_content)
170}
171
172fn set_field(mapping: &mut yaml_serde::Mapping, key: &str, value: &str) {
173    mapping.insert(
174        yaml_serde::Value::String(key.to_string()),
175        yaml_serde::Value::String(value.to_string()),
176    );
177}