1use std::{path::PathBuf, str::FromStr};
15
16use anyhow::{Context, Result};
17use objects::object::OperationId;
18use repo::Repository;
19
20use crate::cli::cli_args::Cli;
21
22const IDEMPOTENT_VERBS: &[&str] = &["capture", "review-sign"];
26
27pub fn resolve_operation_id(cli: &Cli) -> Result<Option<OperationId>> {
34 let Some(raw) = cli.op_id.as_deref() else {
35 return Ok(None);
36 };
37 if raw.trim().is_empty() {
38 return Ok(None);
39 }
40 Ok(Some(
41 OperationId::from_str(raw).context("parse --op-id as UUID v4")?,
42 ))
43}
44
45pub fn wire(cli: &Cli) -> String {
48 cli.op_id.clone().unwrap_or_default()
49}
50
51fn session_dir_for(repo: &Repository) -> PathBuf {
55 use sha2::{Digest, Sha256};
56 let canonical =
57 std::fs::canonicalize(repo.root()).unwrap_or_else(|_| repo.root().to_path_buf());
58 let mut hasher = Sha256::new();
59 hasher.update(canonical.to_string_lossy().as_bytes());
60 let digest = hex::encode(hasher.finalize());
61 let repo_id = &digest[..16.min(digest.len())];
62 let base = std::env::var_os("HOME")
63 .map(PathBuf::from)
64 .unwrap_or_else(std::env::temp_dir);
65 base.join(".heddle").join("session").join(repo_id)
66}
67
68fn last_op_id_path(repo: &Repository) -> PathBuf {
69 session_dir_for(repo).join("last_op_id.toml")
70}
71
72#[derive(serde::Serialize, serde::Deserialize, Default)]
73struct LastOpIdFile {
74 #[serde(default)]
77 by_verb: std::collections::BTreeMap<String, String>,
78}
79
80pub fn resolve_or_persist_for_verb(
90 cli: &Cli,
91 repo: &Repository,
92 verb: &str,
93) -> Result<OperationId> {
94 if let Some(explicit) = resolve_operation_id(cli)? {
95 return Ok(explicit);
96 }
97 if !IDEMPOTENT_VERBS.contains(&verb) {
98 return Ok(OperationId::new());
99 }
100 let path = last_op_id_path(repo);
101 if let Ok(bytes) = std::fs::read(&path)
102 && let Ok(decoded) = toml::from_str::<LastOpIdFile>(&String::from_utf8_lossy(&bytes))
103 && let Some(saved) = decoded.by_verb.get(verb)
104 && let Ok(parsed) = OperationId::from_str(saved)
105 {
106 return Ok(parsed);
107 }
108 let fresh = OperationId::new();
109 persist_op_id(&path, verb, &fresh).context("persist last op id")?;
110 Ok(fresh)
111}
112
113pub fn clear_persisted_op_id(repo: &Repository, verb: &str) -> Result<()> {
117 let path = last_op_id_path(repo);
118 let mut file: LastOpIdFile = match std::fs::read(&path) {
119 Ok(bytes) => toml::from_str(&String::from_utf8_lossy(&bytes)).unwrap_or_default(),
120 Err(_) => return Ok(()),
121 };
122 if file.by_verb.remove(verb).is_none() {
123 return Ok(());
124 }
125 if file.by_verb.is_empty() {
126 let _ = std::fs::remove_file(&path);
127 return Ok(());
128 }
129 let serialized = toml::to_string(&file).context("serialize last_op_id.toml")?;
130 if let Some(parent) = path.parent() {
131 std::fs::create_dir_all(parent)?;
132 }
133 std::fs::write(&path, serialized)?;
134 Ok(())
135}
136
137fn persist_op_id(path: &std::path::Path, verb: &str, op_id: &OperationId) -> Result<()> {
138 let mut file: LastOpIdFile = match std::fs::read(path) {
139 Ok(bytes) => toml::from_str(&String::from_utf8_lossy(&bytes)).unwrap_or_default(),
140 Err(_) => LastOpIdFile::default(),
141 };
142 file.by_verb.insert(verb.to_string(), op_id.to_string());
143 let serialized = toml::to_string(&file).context("serialize last_op_id.toml")?;
144 if let Some(parent) = path.parent() {
145 std::fs::create_dir_all(parent)?;
146 }
147 std::fs::write(path, serialized)?;
148 Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn cli_with(op_id: Option<&str>) -> Cli {
156 let mut cli: Cli = clap::Parser::parse_from(["heddle", "status"]);
157 cli.op_id = op_id.map(|s| s.to_string());
158 cli
159 }
160
161 #[test]
162 fn resolve_none_when_unset() {
163 let cli = cli_with(None);
164 assert!(resolve_operation_id(&cli).unwrap().is_none());
165 }
166
167 #[test]
168 fn resolve_parses_uuid() {
169 let id = OperationId::new();
170 let cli = cli_with(Some(&id.to_string()));
171 assert_eq!(resolve_operation_id(&cli).unwrap(), Some(id));
172 }
173
174 #[test]
175 fn resolve_rejects_garbage() {
176 let cli = cli_with(Some("not-a-uuid"));
177 assert!(resolve_operation_id(&cli).is_err());
178 }
179
180 #[test]
181 fn wire_is_empty_when_unset() {
182 let cli = cli_with(None);
183 assert_eq!(wire(&cli), "");
184 }
185
186 #[test]
187 fn wire_returns_string_when_set() {
188 let id = OperationId::new();
189 let cli = cli_with(Some(&id.to_string()));
190 assert_eq!(wire(&cli), id.to_string());
191 }
192}