1use chrono::Local;
2use std::path::Path;
3
4use crate::config::Config;
5use crate::error::{Error, Result};
6
7pub const SUPERSEDED: &str = "superseded";
15pub const ARCHIVED: &str = "archived";
16pub const DEPRECATED: &str = "deprecated";
17pub const ABANDONED: &str = "abandoned";
18
19pub const LIFECYCLE_TARGET_STATUSES: &[&str] = &[SUPERSEDED, ARCHIVED, DEPRECATED, ABANDONED];
22
23#[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 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 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
64pub 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 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 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(¤t_status) && !matches!(action, Action::Review) {
122 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 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}