enact-config 0.0.2

Unified configuration management for Enact - secure storage with keychain and encrypted files
Documentation
//! Project definitions and taskboards — ENACT_HOME/projects/<slug>/project.yaml and taskboard.yaml

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::config::MemoryConfig;
use crate::home::enact_home;

/// Per-project definition (projects/<slug>/project.yaml).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectDef {
    pub name: String,
    pub slug: String,
    /// Local repo or workspace path.
    #[serde(default)]
    pub repo: Option<String>,
    #[serde(default)]
    pub default_agent: Option<String>,
    #[serde(default)]
    pub agents: Vec<String>,
    #[serde(default)]
    pub description: Option<String>,
    /// Project-level config overrides.
    #[serde(default)]
    pub memory: Option<MemoryConfig>,
}

impl ProjectDef {
    /// Path to this project's directory under ENACT_HOME.
    pub fn project_dir(home: &Path, slug: &str) -> PathBuf {
        home.join("projects").join(slug)
    }

    /// Path to project.yaml.
    pub fn project_yaml_path(home: &Path, slug: &str) -> PathBuf {
        Self::project_dir(home, slug).join("project.yaml")
    }

    /// Path to taskboard.yaml.
    pub fn taskboard_path(home: &Path, slug: &str) -> PathBuf {
        Self::project_dir(home, slug).join("taskboard.yaml")
    }

    /// Path to this project's sessions directory.
    pub fn sessions_dir(home: &Path, slug: &str) -> PathBuf {
        Self::project_dir(home, slug).join("sessions")
    }

    /// Path to this project's context directory (agent-writable).
    pub fn context_dir(home: &Path, slug: &str) -> PathBuf {
        Self::project_dir(home, slug).join("context")
    }

    /// Load project definition from ENACT_HOME/projects/<slug>/project.yaml.
    pub fn load(home: &Path, slug: &str) -> Result<Option<Self>> {
        let path = Self::project_yaml_path(home, slug);
        if !path.exists() {
            return Ok(None);
        }
        let s = std::fs::read_to_string(&path).context("Failed to read project.yaml")?;
        let def: ProjectDef = serde_yaml::from_str(&s).context("Failed to parse project.yaml")?;
        Ok(Some(def))
    }

    /// Save project definition.
    pub fn save(&self, home: &Path) -> Result<()> {
        let dir = Self::project_dir(home, &self.slug);
        std::fs::create_dir_all(&dir).context("Failed to create project directory")?;
        let path = dir.join("project.yaml");
        let s = serde_yaml::to_string(self).context("Failed to serialize project to YAML")?;
        std::fs::write(&path, s).context("Failed to write project.yaml")?;
        Ok(())
    }
}

/// A single task on the taskboard.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Task {
    pub id: String,
    pub title: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub priority: Option<String>,
    #[serde(default)]
    pub assigned_to: Option<String>,
    #[serde(default)]
    pub created_at: Option<String>,
    #[serde(default)]
    pub started_at: Option<String>,
    #[serde(default)]
    pub completed_at: Option<String>,
}

/// Taskboard — todo / in_progress / done (projects/<slug>/taskboard.yaml).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TaskBoard {
    #[serde(default = "default_taskboard_version")]
    pub version: String,
    #[serde(default)]
    pub updated_at: Option<String>,
    #[serde(default)]
    pub todo: Vec<Task>,
    #[serde(default)]
    pub in_progress: Vec<Task>,
    #[serde(default)]
    pub done: Vec<Task>,
}

fn default_taskboard_version() -> String {
    "1.0.0".to_string()
}

impl Default for TaskBoard {
    fn default() -> Self {
        Self {
            version: default_taskboard_version(),
            updated_at: None,
            todo: Vec::new(),
            in_progress: Vec::new(),
            done: Vec::new(),
        }
    }
}

impl TaskBoard {
    /// Load taskboard from ENACT_HOME/projects/<slug>/taskboard.yaml.
    pub fn load(home: &Path, slug: &str) -> Result<Self> {
        let path = ProjectDef::taskboard_path(home, slug);
        if !path.exists() {
            return Ok(Self::default());
        }
        let s = std::fs::read_to_string(&path).context("Failed to read taskboard.yaml")?;
        let board: TaskBoard =
            serde_yaml::from_str(&s).context("Failed to parse taskboard.yaml")?;
        Ok(board)
    }

    /// Save taskboard.
    pub fn save(&self, home: &Path, slug: &str) -> Result<()> {
        let dir = ProjectDef::project_dir(home, slug);
        std::fs::create_dir_all(&dir).context("Failed to create project directory")?;
        let path = dir.join("taskboard.yaml");
        let s = serde_yaml::to_string(self).context("Failed to serialize taskboard to YAML")?;
        std::fs::write(&path, s).context("Failed to write taskboard.yaml")?;
        Ok(())
    }
}

/// Registry that discovers and loads projects from ENACT_HOME/projects/.
pub struct ProjectRegistry;

impl ProjectRegistry {
    /// List project slugs (directory names under projects/ that contain project.yaml).
    pub fn list(home: &Path) -> Result<Vec<String>> {
        let projects_dir = home.join("projects");
        if !projects_dir.exists() {
            return Ok(Vec::new());
        }
        let mut slugs = Vec::new();
        for e in std::fs::read_dir(projects_dir).context("Failed to read projects directory")? {
            let e = e?;
            let path = e.path();
            if path.is_dir() && path.join("project.yaml").exists() {
                if let Some(slug) = path.file_name().and_then(|n| n.to_str()) {
                    slugs.push(slug.to_string());
                }
            }
        }
        slugs.sort();
        Ok(slugs)
    }

    /// Load a single project by slug.
    pub fn get(home: &Path, slug: &str) -> Result<Option<ProjectDef>> {
        ProjectDef::load(home, slug)
    }

    /// Load project from default ENACT_HOME.
    pub fn get_default(slug: &str) -> Result<Option<ProjectDef>> {
        Self::get(&enact_home(), slug)
    }

    /// Load a project together with its taskboard in a single call.
    pub fn get_with_taskboard(home: &Path, slug: &str) -> Result<Option<(ProjectDef, TaskBoard)>> {
        match ProjectDef::load(home, slug)? {
            Some(def) => {
                let board = TaskBoard::load(home, slug)?;
                Ok(Some((def, board)))
            }
            None => Ok(None),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn project_dir_path() {
        let home = Path::new("/tmp/.enact");
        assert_eq!(
            ProjectDef::project_yaml_path(home, "enact-agent"),
            PathBuf::from("/tmp/.enact/projects/enact-agent/project.yaml")
        );
    }
}