Skip to main content

cli/
operation_id.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Client-supplied operation-id resolution.
3//!
4//! The CLI accepts `--op-id <UUID>` (or `HEDDLE_OPERATION_ID`) on every
5//! state-changing verb. When set, the verb passes it through to the
6//! gRPC layer; the dedup store returns the original outcome on replay.
7//! When unset, the call executes without dedup.
8//!
9//! Verbs that have been routed through `with_idempotency` in the
10//! `grpc_local_impl` services already honour the field. Verbs that
11//! still bypass the gRPC layer (most existing core verbs) ignore it
12//! today; wiring lands incrementally as those verbs migrate.
13
14use 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
22/// Verbs whose op-id persists across a `^C → re-run` cycle. Only verbs
23/// whose underlying RPC actually goes through the gRPC dedup store
24/// benefit (without it the persisted id is harmless but inert).
25const IDEMPOTENT_VERBS: &[&str] = &["capture", "review-sign"];
26
27/// Canonical helper used by every state-changing dispatch arm in
28/// `main.rs`. Validates the `--op-id` format eagerly so a malformed
29/// value fails before the verb starts work.
30///
31/// The `op_id_coverage` build-time test grep-asserts a call to this
32/// function in every state-changing arm — keep the name stable.
33pub 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
45/// Same as [`resolve_operation_id`] but returns the wire-string form
46/// expected by gRPC requests. `""` means "no idempotency for this call".
47pub fn wire(cli: &Cli) -> String {
48    cli.op_id.clone().unwrap_or_default()
49}
50
51/// Per-repo session directory under `$HOME/.heddle/session/<repo-id>`.
52/// `<repo-id>` is a 16-char SHA-256 of the canonical repo root so two
53/// worktrees of the same repo don't collide.
54fn 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    /// Per-verb most-recent op-id. Verbs not in [`IDEMPOTENT_VERBS`]
75    /// are never read or written here.
76    #[serde(default)]
77    by_verb: std::collections::BTreeMap<String, String>,
78}
79
80/// Resolve the op-id for a verb that opts into `^C → re-run`
81/// persistence. Order:
82///   1. Caller passed `--op-id` / `HEDDLE_OPERATION_ID` → use it.
83///   2. The verb is in [`IDEMPOTENT_VERBS`] AND a recent saved id
84///      exists for that verb → use it (don't persist; we're reusing).
85///   3. Otherwise generate a fresh id, persist it for the verb, return.
86///
87/// Call [`clear_persisted_op_id`] after the verb completes
88/// successfully so the next run gets a fresh id.
89pub 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
113/// Drop the persisted op-id for `verb`. Called after a successful
114/// response — releases the slot so the next run gets a fresh id
115/// rather than replaying.
116pub 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}