Skip to main content

ao_core/
parity_metadata.rs

1//! TS session metadata persistence (ported from
2//! `packages/core/src/metadata.ts`, `key-value.ts`, `atomic-write.ts`).
3//!
4//! Parity status: test-only.
5//!
6//! Consumed only by other parity modules (`parity_observability`,
7//! `parity_feedback_tools`) and their tests. Production persistence lives
8//! in `session_manager.rs` and related runtime modules. See
9//! `docs/ts-core-parity-report.md` → "Parity-only modules".
10
11use std::collections::HashMap;
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16fn validate_session_id(session_id: &str) -> Result<(), String> {
17    let ok = !session_id.is_empty()
18        && session_id
19            .bytes()
20            .all(|b| matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-'));
21    if ok {
22        Ok(())
23    } else {
24        Err(format!("Invalid session ID: {session_id}"))
25    }
26}
27
28fn metadata_path(data_dir: &Path, session_id: &str) -> Result<PathBuf, String> {
29    validate_session_id(session_id)?;
30    Ok(data_dir.join(session_id))
31}
32
33pub fn parse_key_value_content(content: &str) -> HashMap<String, String> {
34    let mut out = HashMap::new();
35    for line in content.split('\n') {
36        let trimmed = line.trim();
37        if trimmed.is_empty() || trimmed.starts_with('#') {
38            continue;
39        }
40        let Some(eq) = trimmed.find('=') else {
41            continue;
42        };
43        let key = trimmed[..eq].trim();
44        let val = trimmed[eq + 1..].trim();
45        if !key.is_empty() {
46            out.insert(key.to_string(), val.to_string());
47        }
48    }
49    out
50}
51
52fn serialize_metadata(map: &HashMap<String, String>) -> String {
53    let mut lines: Vec<String> = map
54        .iter()
55        .filter(|(_, v)| !v.is_empty())
56        .map(|(k, v)| {
57            let v = v.replace(['\r', '\n'], " ");
58            format!("{k}={v}")
59        })
60        .collect();
61    lines.sort(); // stable content for tests
62    lines.join("\n") + "\n"
63}
64
65pub fn atomic_write_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
66    let tmp = path.with_extension(format!(
67        "tmp.{}.{}",
68        std::process::id(),
69        std::time::SystemTime::now()
70            .duration_since(std::time::UNIX_EPOCH)
71            .unwrap_or_default()
72            .as_millis()
73    ));
74    if let Some(parent) = path.parent() {
75        fs::create_dir_all(parent)?;
76    }
77    {
78        let mut f = fs::File::create(&tmp)?;
79        f.write_all(content.as_bytes())?;
80        f.sync_all()?;
81    }
82    fs::rename(tmp, path)?;
83    Ok(())
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct TsSessionMetadata {
88    pub worktree: String,
89    pub branch: String,
90    pub status: String,
91    pub issue: Option<String>,
92    pub pr: Option<String>,
93    pub pr_auto_detect: Option<String>,
94    pub summary: Option<String>,
95    pub project: Option<String>,
96    pub created_at: Option<String>,
97    pub runtime_handle: Option<String>,
98    pub pinned_summary: Option<String>,
99}
100
101pub fn write_metadata(
102    data_dir: &Path,
103    session_id: &str,
104    meta: &TsSessionMetadata,
105) -> Result<(), String> {
106    let path = metadata_path(data_dir, session_id)?;
107    let mut data: HashMap<String, String> = HashMap::new();
108    data.insert("worktree".into(), meta.worktree.clone());
109    data.insert("branch".into(), meta.branch.clone());
110    data.insert("status".into(), meta.status.clone());
111    if let Some(v) = &meta.issue {
112        data.insert("issue".into(), v.clone());
113    }
114    if let Some(v) = &meta.pr {
115        data.insert("pr".into(), v.clone());
116    }
117    if let Some(v) = &meta.pr_auto_detect {
118        data.insert("prAutoDetect".into(), v.clone());
119    }
120    if let Some(v) = &meta.summary {
121        data.insert("summary".into(), v.clone());
122    }
123    if let Some(v) = &meta.project {
124        data.insert("project".into(), v.clone());
125    }
126    if let Some(v) = &meta.created_at {
127        data.insert("createdAt".into(), v.clone());
128    }
129    if let Some(v) = &meta.runtime_handle {
130        data.insert("runtimeHandle".into(), v.clone());
131    }
132    if let Some(v) = &meta.pinned_summary {
133        data.insert("pinnedSummary".into(), v.clone());
134    }
135    atomic_write_file(&path, &serialize_metadata(&data)).map_err(|e| e.to_string())
136}
137
138pub fn read_metadata_raw(
139    data_dir: &Path,
140    session_id: &str,
141) -> Result<Option<HashMap<String, String>>, String> {
142    let path = metadata_path(data_dir, session_id)?;
143    if !path.exists() {
144        return Ok(None);
145    }
146    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
147    Ok(Some(parse_key_value_content(&content)))
148}
149
150pub fn read_metadata(
151    data_dir: &Path,
152    session_id: &str,
153) -> Result<Option<TsSessionMetadata>, String> {
154    let Some(raw) = read_metadata_raw(data_dir, session_id)? else {
155        return Ok(None);
156    };
157    Ok(Some(TsSessionMetadata {
158        worktree: raw.get("worktree").cloned().unwrap_or_default(),
159        branch: raw.get("branch").cloned().unwrap_or_default(),
160        status: raw
161            .get("status")
162            .cloned()
163            .unwrap_or_else(|| "unknown".into()),
164        issue: raw.get("issue").cloned(),
165        pr: raw.get("pr").cloned(),
166        pr_auto_detect: raw.get("prAutoDetect").cloned(),
167        summary: raw.get("summary").cloned(),
168        project: raw.get("project").cloned(),
169        created_at: raw.get("createdAt").cloned(),
170        runtime_handle: raw.get("runtimeHandle").cloned(),
171        pinned_summary: raw.get("pinnedSummary").cloned(),
172    }))
173}
174
175pub fn update_metadata(
176    data_dir: &Path,
177    session_id: &str,
178    updates: &HashMap<String, String>,
179) -> Result<(), String> {
180    let path = metadata_path(data_dir, session_id)?;
181    let mut existing = if path.exists() {
182        parse_key_value_content(&fs::read_to_string(&path).map_err(|e| e.to_string())?)
183    } else {
184        HashMap::new()
185    };
186    for (k, v) in updates {
187        if v.is_empty() {
188            existing.remove(k);
189        } else {
190            existing.insert(k.clone(), v.clone());
191        }
192    }
193    atomic_write_file(&path, &serialize_metadata(&existing)).map_err(|e| e.to_string())
194}
195
196pub fn delete_metadata(data_dir: &Path, session_id: &str, archive: bool) -> Result<(), String> {
197    let path = metadata_path(data_dir, session_id)?;
198    if !path.exists() {
199        return Ok(());
200    }
201    if archive {
202        let archive_dir = data_dir.join("archive");
203        fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?;
204        let ts = chrono_like_ts();
205        let archive_path = archive_dir.join(format!("{session_id}_{ts}"));
206        fs::write(
207            &archive_path,
208            fs::read_to_string(&path).map_err(|e| e.to_string())?,
209        )
210        .map_err(|e| e.to_string())?;
211    }
212    fs::remove_file(&path).map_err(|e| e.to_string())
213}
214
215fn chrono_like_ts() -> String {
216    use std::time::{SystemTime, UNIX_EPOCH};
217    let ms = SystemTime::now()
218        .duration_since(UNIX_EPOCH)
219        .unwrap_or_default()
220        .as_millis();
221    format!("{ms}")
222}
223
224pub fn read_archived_metadata_raw(
225    data_dir: &Path,
226    session_id: &str,
227) -> Result<Option<HashMap<String, String>>, String> {
228    validate_session_id(session_id)?;
229    let archive_dir = data_dir.join("archive");
230    if !archive_dir.exists() {
231        return Ok(None);
232    }
233    let prefix = format!("{session_id}_");
234    let mut latest: Option<PathBuf> = None;
235    for ent in fs::read_dir(&archive_dir).map_err(|e| e.to_string())? {
236        let ent = ent.map_err(|e| e.to_string())?;
237        let name = ent.file_name().to_string_lossy().to_string();
238        if !name.starts_with(&prefix) {
239            continue;
240        }
241        let replace = match &latest {
242            None => true,
243            Some(p) => {
244                let latest_name = p
245                    .file_name()
246                    .map(|s| s.to_string_lossy().to_string())
247                    .unwrap_or_default();
248                name > latest_name
249            }
250        };
251        if replace {
252            latest = Some(ent.path());
253        }
254    }
255    let Some(path) = latest else { return Ok(None) };
256    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
257    Ok(Some(parse_key_value_content(&content)))
258}
259
260pub fn list_metadata(data_dir: &Path) -> Result<Vec<String>, String> {
261    if !data_dir.exists() {
262        return Ok(vec![]);
263    }
264    let mut out = vec![];
265    for ent in fs::read_dir(data_dir).map_err(|e| e.to_string())? {
266        let ent = ent.map_err(|e| e.to_string())?;
267        if !ent.file_type().map_err(|e| e.to_string())?.is_file() {
268            continue;
269        }
270        let name = ent.file_name().to_string_lossy().to_string();
271        if name == "archive" {
272            continue;
273        }
274        if validate_session_id(&name).is_ok() {
275            out.push(name);
276        }
277    }
278    out.sort();
279    Ok(out)
280}