roboticus_agent/
workspace.rs1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use tracing::info;
4
5#[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#[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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct SchemaRef {
27 pub name: String,
28 pub path: String,
29}
30
31#[derive(Debug, Clone)]
33pub struct FileEntry {
34 pub path: PathBuf,
35 pub category: FileCategory,
36 pub size_bytes: u64,
37}
38
39#[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 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 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 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 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 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}