1use 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(); 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}