Skip to main content

roboticus_agent/
workspace.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use tracing::info;
4
5/// Represents the structured workspace context for an agent.
6#[derive(Debug, Clone)]
7pub struct WorkspaceContext {
8    pub root: PathBuf,
9    pub manifest: Option<WorkspaceManifest>,
10    pub file_index: HashMap<String, FileEntry>,
11}
12
13/// A TOML-parsed workspace manifest (workspace.toml).
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct WorkspaceManifest {
16    #[serde(default)]
17    pub name: String,
18    #[serde(default)]
19    pub description: String,
20    #[serde(default)]
21    pub schemas: Vec<SchemaRef>,
22}
23
24/// Reference to a data schema file.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct SchemaRef {
27    pub name: String,
28    pub path: String,
29}
30
31/// An indexed file in the workspace.
32#[derive(Debug, Clone)]
33pub struct FileEntry {
34    pub path: PathBuf,
35    pub category: FileCategory,
36    pub size_bytes: u64,
37}
38
39/// Categories of files in the workspace.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum FileCategory {
42    Personality,
43    Config,
44    Schema,
45    Document,
46    Data,
47    Unknown,
48}
49
50impl FileCategory {
51    pub fn from_path(path: &Path) -> Self {
52        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
53        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
54
55        match name {
56            "SOUL.md" | "FIRMWARE.md" | "OPERATOR.md" | "DIRECTIVES.md" => {
57                FileCategory::Personality
58            }
59            "workspace.toml" | "config.toml" => FileCategory::Config,
60            _ => match ext {
61                "toml" | "yaml" | "yml" => FileCategory::Schema,
62                "md" | "txt" | "rst" => FileCategory::Document,
63                "json" | "csv" | "sqlite" | "db" => FileCategory::Data,
64                _ => FileCategory::Unknown,
65            },
66        }
67    }
68}
69
70impl WorkspaceContext {
71    /// Load workspace context from a directory.
72    pub fn from_path(root: &Path) -> Self {
73        let manifest = Self::load_manifest(root);
74        let file_index = Self::index_files(root);
75
76        info!(
77            root = %root.display(),
78            files = file_index.len(),
79            has_manifest = manifest.is_some(),
80            "loaded workspace context"
81        );
82
83        Self {
84            root: root.to_path_buf(),
85            manifest,
86            file_index,
87        }
88    }
89
90    fn load_manifest(root: &Path) -> Option<WorkspaceManifest> {
91        let manifest_path = root.join("workspace.toml");
92        if !manifest_path.exists() {
93            return None;
94        }
95        let content = std::fs::read_to_string(&manifest_path).ok()?;
96        toml::from_str(&content).ok()
97    }
98
99    fn index_files(root: &Path) -> HashMap<String, FileEntry> {
100        let mut index = HashMap::new();
101        let Ok(entries) = std::fs::read_dir(root) else {
102            return index;
103        };
104        for entry in entries.flatten() {
105            let path = entry.path();
106            if path.is_file() {
107                let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
108                let key = path
109                    .file_name()
110                    .and_then(|n| n.to_str())
111                    .unwrap_or("")
112                    .to_string();
113                let category = FileCategory::from_path(&path);
114                index.insert(
115                    key,
116                    FileEntry {
117                        path: path.clone(),
118                        category,
119                        size_bytes: size,
120                    },
121                );
122            }
123        }
124        index
125    }
126
127    /// Generate a summary of the workspace for agent context.
128    pub fn summary(&self) -> String {
129        let mut parts = Vec::new();
130        parts.push(format!("Workspace: {}", self.root.display()));
131
132        if let Some(ref manifest) = self.manifest {
133            if !manifest.name.is_empty() {
134                parts.push(format!("Name: {}", manifest.name));
135            }
136            if !manifest.description.is_empty() {
137                parts.push(format!("Description: {}", manifest.description));
138            }
139        }
140
141        let personality_count = self.files_by_category(FileCategory::Personality).len();
142        let config_count = self.files_by_category(FileCategory::Config).len();
143        let doc_count = self.files_by_category(FileCategory::Document).len();
144        let data_count = self.files_by_category(FileCategory::Data).len();
145
146        parts.push(format!(
147            "Files: {} personality, {} config, {} documents, {} data",
148            personality_count, config_count, doc_count, data_count
149        ));
150
151        parts.join("\n")
152    }
153
154    /// Get files by category.
155    pub fn files_by_category(&self, category: FileCategory) -> Vec<&FileEntry> {
156        self.file_index
157            .values()
158            .filter(|f| f.category == category)
159            .collect()
160    }
161
162    /// Check if a specific personality file exists.
163    pub fn has_personality_file(&self, name: &str) -> bool {
164        self.file_index
165            .get(name)
166            .map(|f| f.category == FileCategory::Personality)
167            .unwrap_or(false)
168    }
169
170    /// Get the total workspace size in bytes.
171    pub fn total_size(&self) -> u64 {
172        self.file_index.values().map(|f| f.size_bytes).sum()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::fs;
180    use tempfile::TempDir;
181
182    #[test]
183    fn file_category_personality() {
184        assert_eq!(
185            FileCategory::from_path(Path::new("SOUL.md")),
186            FileCategory::Personality
187        );
188        assert_eq!(
189            FileCategory::from_path(Path::new("FIRMWARE.md")),
190            FileCategory::Personality
191        );
192    }
193
194    #[test]
195    fn file_category_config() {
196        assert_eq!(
197            FileCategory::from_path(Path::new("workspace.toml")),
198            FileCategory::Config
199        );
200        assert_eq!(
201            FileCategory::from_path(Path::new("config.toml")),
202            FileCategory::Config
203        );
204    }
205
206    #[test]
207    fn file_category_document() {
208        assert_eq!(
209            FileCategory::from_path(Path::new("README.md")),
210            FileCategory::Document
211        );
212        assert_eq!(
213            FileCategory::from_path(Path::new("notes.txt")),
214            FileCategory::Document
215        );
216    }
217
218    #[test]
219    fn file_category_schema() {
220        assert_eq!(
221            FileCategory::from_path(Path::new("schema.yaml")),
222            FileCategory::Schema
223        );
224    }
225
226    #[test]
227    fn file_category_data() {
228        assert_eq!(
229            FileCategory::from_path(Path::new("export.json")),
230            FileCategory::Data
231        );
232        assert_eq!(
233            FileCategory::from_path(Path::new("records.csv")),
234            FileCategory::Data
235        );
236    }
237
238    #[test]
239    fn workspace_from_nonexistent_path() {
240        let ctx = WorkspaceContext::from_path(Path::new("/nonexistent/workspace"));
241        assert!(ctx.manifest.is_none());
242        assert!(ctx.file_index.is_empty());
243    }
244
245    #[test]
246    fn workspace_from_empty_dir() {
247        let dir = TempDir::new().unwrap();
248        let ctx = WorkspaceContext::from_path(dir.path());
249        assert!(ctx.manifest.is_none());
250        assert!(ctx.file_index.is_empty());
251    }
252
253    #[test]
254    fn workspace_indexes_files() {
255        let dir = TempDir::new().unwrap();
256        fs::write(dir.path().join("SOUL.md"), "# Identity").unwrap();
257        fs::write(dir.path().join("notes.txt"), "Some notes").unwrap();
258
259        let ctx = WorkspaceContext::from_path(dir.path());
260        assert_eq!(ctx.file_index.len(), 2);
261        assert!(ctx.has_personality_file("SOUL.md"));
262    }
263
264    #[test]
265    fn workspace_loads_manifest() {
266        let dir = TempDir::new().unwrap();
267        fs::write(
268            dir.path().join("workspace.toml"),
269            r#"
270name = "TestWorkspace"
271description = "A test workspace"
272"#,
273        )
274        .unwrap();
275
276        let ctx = WorkspaceContext::from_path(dir.path());
277        let manifest = ctx.manifest.as_ref().unwrap();
278        assert_eq!(manifest.name, "TestWorkspace");
279        assert_eq!(manifest.description, "A test workspace");
280    }
281
282    #[test]
283    fn workspace_summary_contains_info() {
284        let dir = TempDir::new().unwrap();
285        fs::write(dir.path().join("SOUL.md"), "# Soul").unwrap();
286        fs::write(dir.path().join("config.toml"), "key = 'val'").unwrap();
287
288        let ctx = WorkspaceContext::from_path(dir.path());
289        let summary = ctx.summary();
290        assert!(summary.contains("1 personality"));
291        assert!(summary.contains("1 config"));
292    }
293
294    #[test]
295    fn workspace_total_size() {
296        let dir = TempDir::new().unwrap();
297        fs::write(dir.path().join("file.md"), "hello").unwrap();
298
299        let ctx = WorkspaceContext::from_path(dir.path());
300        assert!(ctx.total_size() > 0);
301    }
302
303    #[test]
304    fn files_by_category_filters() {
305        let dir = TempDir::new().unwrap();
306        fs::write(dir.path().join("SOUL.md"), "soul").unwrap();
307        fs::write(dir.path().join("README.md"), "readme").unwrap();
308        fs::write(dir.path().join("data.json"), "{}").unwrap();
309
310        let ctx = WorkspaceContext::from_path(dir.path());
311        assert_eq!(ctx.files_by_category(FileCategory::Personality).len(), 1);
312        assert_eq!(ctx.files_by_category(FileCategory::Document).len(), 1);
313        assert_eq!(ctx.files_by_category(FileCategory::Data).len(), 1);
314    }
315}