Skip to main content

opendev_runtime/
plan_index.rs

1//! Plan index manager for tracking plan-session-project associations.
2//!
3//! Stores a lightweight JSON index at `~/.opendev/plans/plans-index.json`
4//! following atomic-write patterns (tempfile + rename).
5//!
6//! Ported from `opendev/core/runtime/plan_index.py`.
7
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use std::io::Write;
11use std::path::PathBuf;
12use tracing::warn;
13
14const INDEX_FILE: &str = "plans-index.json";
15const VERSION: u32 = 1;
16
17/// A single plan entry in the index.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PlanEntry {
20    pub name: String,
21    #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
22    pub session_id: Option<String>,
23    #[serde(rename = "projectPath", skip_serializing_if = "Option::is_none")]
24    pub project_path: Option<String>,
25    pub created: String,
26}
27
28/// On-disk format for plans-index.json.
29#[derive(Debug, Serialize, Deserialize)]
30struct IndexData {
31    version: u32,
32    entries: Vec<PlanEntry>,
33}
34
35impl Default for IndexData {
36    fn default() -> Self {
37        Self {
38            version: VERSION,
39            entries: Vec::new(),
40        }
41    }
42}
43
44/// Manage the plans-index.json file for plan-session-project tracking.
45pub struct PlanIndex {
46    plans_dir: PathBuf,
47    index_path: PathBuf,
48}
49
50impl PlanIndex {
51    /// Create a new plan index manager.
52    ///
53    /// # Arguments
54    /// * `plans_dir` - Directory containing plan files (e.g. `~/.opendev/plans/`).
55    pub fn new(plans_dir: impl Into<PathBuf>) -> Self {
56        let dir = plans_dir.into();
57        let index_path = dir.join(INDEX_FILE);
58        Self {
59            plans_dir: dir,
60            index_path,
61        }
62    }
63
64    /// Read the index file, returning default structure if missing or invalid.
65    fn read_index(&self) -> IndexData {
66        if !self.index_path.exists() {
67            return IndexData::default();
68        }
69        match std::fs::read_to_string(&self.index_path) {
70            Ok(content) => serde_json::from_str::<IndexData>(&content).unwrap_or_default(),
71            Err(_) => IndexData::default(),
72        }
73    }
74
75    /// Atomically write the index file (tempfile + rename).
76    fn write_index(&self, data: &IndexData) -> std::io::Result<()> {
77        std::fs::create_dir_all(&self.plans_dir)?;
78
79        let tmp_path = self.plans_dir.join(".plans-idx-tmp");
80        {
81            let mut f = std::fs::File::create(&tmp_path)?;
82            let json = serde_json::to_string_pretty(data).map_err(std::io::Error::other)?;
83            f.write_all(json.as_bytes())?;
84            f.write_all(b"\n")?;
85            f.sync_all()?;
86        }
87
88        std::fs::rename(&tmp_path, &self.index_path).inspect_err(|_| {
89            let _ = std::fs::remove_file(&tmp_path);
90        })
91    }
92
93    /// Add or update an entry in the plan index.
94    ///
95    /// If an entry with the same name already exists, it is replaced (upsert).
96    pub fn add_entry(&self, name: &str, session_id: Option<&str>, project_path: Option<&str>) {
97        let mut data = self.read_index();
98
99        // Upsert: remove existing entry with same name
100        data.entries.retain(|e| e.name != name);
101
102        data.entries.push(PlanEntry {
103            name: name.to_string(),
104            session_id: session_id.map(|s| s.to_string()),
105            project_path: project_path.map(|s| s.to_string()),
106            created: Utc::now().to_rfc3339(),
107        });
108
109        if let Err(e) = self.write_index(&data) {
110            warn!("Failed to write plan index: {}", e);
111        }
112    }
113
114    /// Look up a plan entry by session ID.
115    pub fn get_by_session(&self, session_id: &str) -> Option<PlanEntry> {
116        self.read_index()
117            .entries
118            .into_iter()
119            .find(|e| e.session_id.as_deref() == Some(session_id))
120    }
121
122    /// List all plan entries for a project.
123    pub fn get_by_project(&self, project_path: &str) -> Vec<PlanEntry> {
124        self.read_index()
125            .entries
126            .into_iter()
127            .filter(|e| e.project_path.as_deref() == Some(project_path))
128            .collect()
129    }
130
131    /// Remove an entry by plan name.
132    pub fn remove_entry(&self, name: &str) {
133        let mut data = self.read_index();
134        data.entries.retain(|e| e.name != name);
135        if let Err(e) = self.write_index(&data) {
136            warn!("Failed to write plan index: {}", e);
137        }
138    }
139
140    /// List all entries in the index.
141    pub fn list_all(&self) -> Vec<PlanEntry> {
142        self.read_index().entries
143    }
144}
145
146#[cfg(test)]
147#[path = "plan_index_tests.rs"]
148mod tests;