agent_air_runtime/controller/tools/
plan_store.rs1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use tokio::sync::{Mutex, RwLock};
12
13const PLANS_DIR_NAME: &str = ".agent-air/plans";
15
16pub const PENDING_MARKER: &str = " ";
18pub const IN_PROGRESS_MARKER: &str = "~";
20pub const COMPLETED_MARKER: &str = "x";
22pub const SKIPPED_MARKER: &str = "-";
24
25pub struct PlanStore {
27 plans_dir: PathBuf,
29 file_locks: RwLock<HashMap<PathBuf, Arc<Mutex<()>>>>,
31}
32
33impl PlanStore {
34 pub fn new(workspace_root: PathBuf) -> Self {
36 Self {
37 plans_dir: workspace_root.join(PLANS_DIR_NAME),
38 file_locks: RwLock::new(HashMap::new()),
39 }
40 }
41
42 pub fn plans_dir(&self) -> &Path {
44 &self.plans_dir
45 }
46
47 pub async fn acquire_lock(&self, path: &Path) -> Arc<Mutex<()>> {
49 {
51 let locks = self.file_locks.read().await;
52 if let Some(lock) = locks.get(path) {
53 return lock.clone();
54 }
55 }
56
57 let mut locks = self.file_locks.write().await;
59 locks
60 .entry(path.to_path_buf())
61 .or_insert_with(|| Arc::new(Mutex::new(())))
62 .clone()
63 }
64
65 pub async fn get_next_plan_id(&self) -> Result<String, String> {
68 let plans_dir = &self.plans_dir;
69
70 if !plans_dir.exists() {
72 return Ok("plan-001".to_string());
73 }
74
75 let mut max_num: u32 = 0;
76
77 let mut entries = tokio::fs::read_dir(plans_dir)
78 .await
79 .map_err(|e| format!("Failed to read plans directory: {}", e))?;
80
81 while let Some(entry) = entries
82 .next_entry()
83 .await
84 .map_err(|e| format!("Failed to read directory entry: {}", e))?
85 {
86 let file_name = entry.file_name();
87 let name = file_name.to_string_lossy();
88 if let Some(num_str) = name
89 .strip_prefix("plan-")
90 .and_then(|s| s.strip_suffix(".md"))
91 && let Ok(num) = num_str.parse::<u32>()
92 && num > max_num
93 {
94 max_num = num;
95 }
96 }
97
98 Ok(format!("plan-{:03}", max_num + 1))
99 }
100
101 pub fn status_to_marker(status: &str) -> Result<&'static str, String> {
103 match status {
104 "pending" => Ok(PENDING_MARKER),
105 "in_progress" => Ok(IN_PROGRESS_MARKER),
106 "completed" => Ok(COMPLETED_MARKER),
107 "skipped" => Ok(SKIPPED_MARKER),
108 _ => Err(format!(
109 "Invalid step status '{}'. Must be one of: pending, in_progress, completed, skipped",
110 status
111 )),
112 }
113 }
114
115 pub fn marker_to_status(marker: &str) -> &'static str {
117 match marker {
118 " " => "pending",
119 "~" => "in_progress",
120 "x" => "completed",
121 "-" => "skipped",
122 _ => "pending",
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use tempfile::TempDir;
131
132 #[tokio::test]
133 async fn test_get_next_plan_id_empty_dir() {
134 let temp_dir = TempDir::new().unwrap();
135 let store = PlanStore::new(temp_dir.path().to_path_buf());
136
137 let id = store.get_next_plan_id().await.unwrap();
139 assert_eq!(id, "plan-001");
140 }
141
142 #[tokio::test]
143 async fn test_get_next_plan_id_existing_plans() {
144 let temp_dir = TempDir::new().unwrap();
145 let store = PlanStore::new(temp_dir.path().to_path_buf());
146
147 let plans_dir = temp_dir.path().join(PLANS_DIR_NAME);
149 tokio::fs::create_dir_all(&plans_dir).await.unwrap();
150 tokio::fs::write(plans_dir.join("plan-001.md"), "# Plan 1")
151 .await
152 .unwrap();
153 tokio::fs::write(plans_dir.join("plan-003.md"), "# Plan 3")
154 .await
155 .unwrap();
156 tokio::fs::write(plans_dir.join("notes.md"), "# Notes")
158 .await
159 .unwrap();
160
161 let id = store.get_next_plan_id().await.unwrap();
162 assert_eq!(id, "plan-004");
163 }
164
165 #[test]
166 fn test_plans_dir_derived_from_workspace_root() {
167 let store = PlanStore::new(PathBuf::from("/workspace/root"));
168 assert_eq!(
169 store.plans_dir(),
170 Path::new("/workspace/root/.agent-air/plans")
171 );
172 }
173
174 #[test]
175 fn test_status_to_marker() {
176 assert_eq!(PlanStore::status_to_marker("pending").unwrap(), " ");
177 assert_eq!(PlanStore::status_to_marker("in_progress").unwrap(), "~");
178 assert_eq!(PlanStore::status_to_marker("completed").unwrap(), "x");
179 assert_eq!(PlanStore::status_to_marker("skipped").unwrap(), "-");
180 assert!(PlanStore::status_to_marker("invalid").is_err());
181 }
182
183 #[test]
184 fn test_marker_to_status() {
185 assert_eq!(PlanStore::marker_to_status(" "), "pending");
186 assert_eq!(PlanStore::marker_to_status("~"), "in_progress");
187 assert_eq!(PlanStore::marker_to_status("x"), "completed");
188 assert_eq!(PlanStore::marker_to_status("-"), "skipped");
189 assert_eq!(PlanStore::marker_to_status("?"), "pending");
191 }
192
193 #[tokio::test]
194 async fn test_acquire_lock_returns_same_lock_for_same_path() {
195 let temp_dir = TempDir::new().unwrap();
196 let store = PlanStore::new(temp_dir.path().to_path_buf());
197
198 let path = PathBuf::from("/some/plan.md");
199 let lock1 = store.acquire_lock(&path).await;
200 let lock2 = store.acquire_lock(&path).await;
201
202 assert!(Arc::ptr_eq(&lock1, &lock2));
204 }
205
206 #[tokio::test]
207 async fn test_acquire_lock_returns_different_locks_for_different_paths() {
208 let temp_dir = TempDir::new().unwrap();
209 let store = PlanStore::new(temp_dir.path().to_path_buf());
210
211 let path_a = PathBuf::from("/some/plan-a.md");
212 let path_b = PathBuf::from("/some/plan-b.md");
213 let lock_a = store.acquire_lock(&path_a).await;
214 let lock_b = store.acquire_lock(&path_b).await;
215
216 assert!(!Arc::ptr_eq(&lock_a, &lock_b));
217 }
218}