Skip to main content

chant/worktree/
status.rs

1//! Agent status file format for worktree communication
2//!
3//! Provides the data structure and I/O operations for agents to communicate
4//! their status to the watch process via `.chant-status.json` files.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11/// Status enum for agent execution state
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum AgentStatusState {
15    /// Agent is currently working on the spec
16    Working,
17    /// Agent has successfully completed the spec
18    Done,
19    /// Agent has failed to complete the spec
20    Failed,
21}
22
23/// Status information written by agent to communicate with watch
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct AgentStatus {
26    /// The spec ID this status refers to
27    pub spec_id: String,
28    /// Current status of agent execution
29    pub status: AgentStatusState,
30    /// When this status was last updated (ISO 8601 timestamp)
31    pub updated_at: String,
32    /// Error message if status is Failed
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub error: Option<String>,
35    /// Commit hashes produced by the agent
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub commits: Vec<String>,
38}
39
40/// Write agent status to a file atomically
41///
42/// Uses a temporary file + rename strategy to ensure atomic writes
43/// and prevent corruption from concurrent access.
44///
45/// # Arguments
46///
47/// * `path` - Path where the status file should be written
48/// * `status` - The status data to write
49///
50/// # Returns
51///
52/// Ok(()) if write succeeds, Err otherwise
53pub fn write_status(path: &Path, status: &AgentStatus) -> Result<()> {
54    // Serialize to JSON
55    let json =
56        serde_json::to_string_pretty(status).context("Failed to serialize AgentStatus to JSON")?;
57
58    // Write to temporary file
59    let temp_path = path.with_extension("tmp");
60    fs::write(&temp_path, json).context(format!(
61        "Failed to write status to temporary file: {:?}",
62        temp_path
63    ))?;
64
65    // Atomically rename temp file to final destination
66    fs::rename(&temp_path, path).context(format!(
67        "Failed to rename temporary status file to: {:?}",
68        path
69    ))?;
70
71    Ok(())
72}
73
74/// Read agent status from a file
75///
76/// # Arguments
77///
78/// * `path` - Path to the status file
79///
80/// # Returns
81///
82/// Ok(AgentStatus) if read and parse succeed, Err otherwise
83///
84/// # Errors
85///
86/// Returns distinct errors for:
87/// - Missing file (file not found)
88/// - Corrupt JSON (parse error)
89/// - I/O errors
90pub fn read_status(path: &Path) -> Result<AgentStatus> {
91    // Check if file exists first to provide a clear error
92    if !path.exists() {
93        anyhow::bail!("Status file not found at {:?}", path);
94    }
95
96    // Read file contents
97    let contents =
98        fs::read_to_string(path).context(format!("Failed to read status file: {:?}", path))?;
99
100    // Parse JSON
101    let status: AgentStatus = serde_json::from_str(&contents)
102        .context(format!("Failed to parse status file as JSON: {:?}", path))?;
103
104    Ok(status)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::fs;
111    use tempfile::TempDir;
112
113    #[test]
114    fn test_serialization_round_trip() {
115        let status = AgentStatus {
116            spec_id: "2026-02-03-test".to_string(),
117            status: AgentStatusState::Done,
118            updated_at: "2026-02-03T10:00:00Z".to_string(),
119            error: None,
120            commits: vec!["abc123".to_string()],
121        };
122
123        let json = serde_json::to_string(&status).unwrap();
124        let deserialized: AgentStatus = serde_json::from_str(&json).unwrap();
125
126        assert_eq!(deserialized.spec_id, "2026-02-03-test");
127        assert_eq!(deserialized.status, AgentStatusState::Done);
128        assert_eq!(deserialized.updated_at, "2026-02-03T10:00:00Z");
129        assert_eq!(deserialized.error, None);
130        assert_eq!(deserialized.commits, vec!["abc123"]);
131    }
132
133    #[test]
134    fn test_write_status_atomic() {
135        let temp_dir = TempDir::new().unwrap();
136        let status_path = temp_dir.path().join(".chant-status.json");
137
138        let status = AgentStatus {
139            spec_id: "2026-02-03-test".to_string(),
140            status: AgentStatusState::Working,
141            updated_at: "2026-02-03T10:00:00Z".to_string(),
142            error: None,
143            commits: vec![],
144        };
145
146        write_status(&status_path, &status).unwrap();
147
148        // Verify final file exists
149        assert!(status_path.exists());
150
151        // Verify temp file was cleaned up
152        let temp_path = status_path.with_extension("tmp");
153        assert!(!temp_path.exists());
154
155        // Verify contents are correct
156        let read_back = read_status(&status_path).unwrap();
157        assert_eq!(read_back.spec_id, status.spec_id);
158        assert_eq!(read_back.status, status.status);
159    }
160
161    #[test]
162    fn test_read_status_missing_file() {
163        let temp_dir = TempDir::new().unwrap();
164        let status_path = temp_dir.path().join("nonexistent.json");
165
166        let result = read_status(&status_path);
167        assert!(result.is_err());
168        let err_msg = result.unwrap_err().to_string();
169        assert!(err_msg.contains("not found"));
170    }
171
172    #[test]
173    fn test_read_status_corrupt_json() {
174        let temp_dir = TempDir::new().unwrap();
175        let status_path = temp_dir.path().join("corrupt.json");
176
177        // Write invalid JSON
178        fs::write(&status_path, "{ invalid json }").unwrap();
179
180        let result = read_status(&status_path);
181        assert!(result.is_err());
182        let err_msg = result.unwrap_err().to_string();
183        assert!(err_msg.contains("parse"));
184    }
185
186    #[test]
187    fn test_status_with_error() {
188        let status = AgentStatus {
189            spec_id: "2026-02-03-test".to_string(),
190            status: AgentStatusState::Failed,
191            updated_at: "2026-02-03T10:00:00Z".to_string(),
192            error: Some("Build failed".to_string()),
193            commits: vec![],
194        };
195
196        let json = serde_json::to_string(&status).unwrap();
197        let deserialized: AgentStatus = serde_json::from_str(&json).unwrap();
198
199        assert_eq!(deserialized.status, AgentStatusState::Failed);
200        assert_eq!(deserialized.error, Some("Build failed".to_string()));
201    }
202
203    #[test]
204    fn test_status_multiple_commits() {
205        let status = AgentStatus {
206            spec_id: "2026-02-03-test".to_string(),
207            status: AgentStatusState::Done,
208            updated_at: "2026-02-03T10:00:00Z".to_string(),
209            error: None,
210            commits: vec![
211                "abc123".to_string(),
212                "def456".to_string(),
213                "ghi789".to_string(),
214            ],
215        };
216
217        let json = serde_json::to_string(&status).unwrap();
218        let deserialized: AgentStatus = serde_json::from_str(&json).unwrap();
219
220        assert_eq!(deserialized.commits.len(), 3);
221        assert_eq!(deserialized.commits[0], "abc123");
222        assert_eq!(deserialized.commits[2], "ghi789");
223    }
224}