Skip to main content

enact_config/
project_def.rs

1//! Project definitions and taskboards — ENACT_HOME/projects/<slug>/project.yaml and taskboard.yaml
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7use crate::config::MemoryConfig;
8use crate::home::enact_home;
9
10/// Per-project definition (projects/<slug>/project.yaml).
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct ProjectDef {
13    pub name: String,
14    pub slug: String,
15    /// Local repo or workspace path.
16    #[serde(default)]
17    pub repo: Option<String>,
18    #[serde(default)]
19    pub default_agent: Option<String>,
20    #[serde(default)]
21    pub agents: Vec<String>,
22    #[serde(default)]
23    pub description: Option<String>,
24    /// Project-level config overrides.
25    #[serde(default)]
26    pub memory: Option<MemoryConfig>,
27}
28
29impl ProjectDef {
30    /// Path to this project's directory under ENACT_HOME.
31    pub fn project_dir(home: &Path, slug: &str) -> PathBuf {
32        home.join("projects").join(slug)
33    }
34
35    /// Path to project.yaml.
36    pub fn project_yaml_path(home: &Path, slug: &str) -> PathBuf {
37        Self::project_dir(home, slug).join("project.yaml")
38    }
39
40    /// Path to taskboard.yaml.
41    pub fn taskboard_path(home: &Path, slug: &str) -> PathBuf {
42        Self::project_dir(home, slug).join("taskboard.yaml")
43    }
44
45    /// Path to this project's sessions directory.
46    pub fn sessions_dir(home: &Path, slug: &str) -> PathBuf {
47        Self::project_dir(home, slug).join("sessions")
48    }
49
50    /// Path to this project's context directory (agent-writable).
51    pub fn context_dir(home: &Path, slug: &str) -> PathBuf {
52        Self::project_dir(home, slug).join("context")
53    }
54
55    /// Load project definition from ENACT_HOME/projects/<slug>/project.yaml.
56    pub fn load(home: &Path, slug: &str) -> Result<Option<Self>> {
57        let path = Self::project_yaml_path(home, slug);
58        if !path.exists() {
59            return Ok(None);
60        }
61        let s = std::fs::read_to_string(&path).context("Failed to read project.yaml")?;
62        let def: ProjectDef = serde_yaml::from_str(&s).context("Failed to parse project.yaml")?;
63        Ok(Some(def))
64    }
65
66    /// Save project definition.
67    pub fn save(&self, home: &Path) -> Result<()> {
68        let dir = Self::project_dir(home, &self.slug);
69        std::fs::create_dir_all(&dir).context("Failed to create project directory")?;
70        let path = dir.join("project.yaml");
71        let s = serde_yaml::to_string(self).context("Failed to serialize project to YAML")?;
72        std::fs::write(&path, s).context("Failed to write project.yaml")?;
73        Ok(())
74    }
75}
76
77/// A single task on the taskboard.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct Task {
80    pub id: String,
81    pub title: String,
82    #[serde(default)]
83    pub description: Option<String>,
84    #[serde(default)]
85    pub priority: Option<String>,
86    #[serde(default)]
87    pub assigned_to: Option<String>,
88    #[serde(default)]
89    pub created_at: Option<String>,
90    #[serde(default)]
91    pub started_at: Option<String>,
92    #[serde(default)]
93    pub completed_at: Option<String>,
94}
95
96/// Taskboard — todo / in_progress / done (projects/<slug>/taskboard.yaml).
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98pub struct TaskBoard {
99    #[serde(default = "default_taskboard_version")]
100    pub version: String,
101    #[serde(default)]
102    pub updated_at: Option<String>,
103    #[serde(default)]
104    pub todo: Vec<Task>,
105    #[serde(default)]
106    pub in_progress: Vec<Task>,
107    #[serde(default)]
108    pub done: Vec<Task>,
109}
110
111fn default_taskboard_version() -> String {
112    "1.0.0".to_string()
113}
114
115impl Default for TaskBoard {
116    fn default() -> Self {
117        Self {
118            version: default_taskboard_version(),
119            updated_at: None,
120            todo: Vec::new(),
121            in_progress: Vec::new(),
122            done: Vec::new(),
123        }
124    }
125}
126
127impl TaskBoard {
128    /// Load taskboard from ENACT_HOME/projects/<slug>/taskboard.yaml.
129    pub fn load(home: &Path, slug: &str) -> Result<Self> {
130        let path = ProjectDef::taskboard_path(home, slug);
131        if !path.exists() {
132            return Ok(Self::default());
133        }
134        let s = std::fs::read_to_string(&path).context("Failed to read taskboard.yaml")?;
135        let board: TaskBoard =
136            serde_yaml::from_str(&s).context("Failed to parse taskboard.yaml")?;
137        Ok(board)
138    }
139
140    /// Save taskboard.
141    pub fn save(&self, home: &Path, slug: &str) -> Result<()> {
142        let dir = ProjectDef::project_dir(home, slug);
143        std::fs::create_dir_all(&dir).context("Failed to create project directory")?;
144        let path = dir.join("taskboard.yaml");
145        let s = serde_yaml::to_string(self).context("Failed to serialize taskboard to YAML")?;
146        std::fs::write(&path, s).context("Failed to write taskboard.yaml")?;
147        Ok(())
148    }
149}
150
151/// Registry that discovers and loads projects from ENACT_HOME/projects/.
152pub struct ProjectRegistry;
153
154impl ProjectRegistry {
155    /// List project slugs (directory names under projects/ that contain project.yaml).
156    pub fn list(home: &Path) -> Result<Vec<String>> {
157        let projects_dir = home.join("projects");
158        if !projects_dir.exists() {
159            return Ok(Vec::new());
160        }
161        let mut slugs = Vec::new();
162        for e in std::fs::read_dir(projects_dir).context("Failed to read projects directory")? {
163            let e = e?;
164            let path = e.path();
165            if path.is_dir() && path.join("project.yaml").exists() {
166                if let Some(slug) = path.file_name().and_then(|n| n.to_str()) {
167                    slugs.push(slug.to_string());
168                }
169            }
170        }
171        slugs.sort();
172        Ok(slugs)
173    }
174
175    /// Load a single project by slug.
176    pub fn get(home: &Path, slug: &str) -> Result<Option<ProjectDef>> {
177        ProjectDef::load(home, slug)
178    }
179
180    /// Load project from default ENACT_HOME.
181    pub fn get_default(slug: &str) -> Result<Option<ProjectDef>> {
182        Self::get(&enact_home(), slug)
183    }
184
185    /// Load a project together with its taskboard in a single call.
186    pub fn get_with_taskboard(home: &Path, slug: &str) -> Result<Option<(ProjectDef, TaskBoard)>> {
187        match ProjectDef::load(home, slug)? {
188            Some(def) => {
189                let board = TaskBoard::load(home, slug)?;
190                Ok(Some((def, board)))
191            }
192            None => Ok(None),
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn project_dir_path() {
203        let home = Path::new("/tmp/.enact");
204        assert_eq!(
205            ProjectDef::project_yaml_path(home, "enact-agent"),
206            PathBuf::from("/tmp/.enact/projects/enact-agent/project.yaml")
207        );
208    }
209}