Skip to main content

oxios_kernel/program/
mod.rs

1//! Programs: OS-level installable applications for AI agents.
2//!
3//! A program is the OS-level concept of an installable application.
4//! Like Unix has /bin programs, Oxios has programs that agents can "execute"
5//! to gain capabilities through their SKILL.md instruction files.
6//!
7//! # Structure
8//!
9//! A program directory contains:
10//! - `program.toml` - metadata (name, version, description, tools, dependencies)
11//! - `SKILL.md` - instruction file (like a man page)
12//! - optional `bin/` - executables
13//! - optional `config/` - configuration files
14//!
15//! # Philosophy
16//!
17//! Programs are READ-ONLY instruction sets. They don't execute themselves;
18//! they provide guidelines and tools that agents consume. Think of them as
19//! man pages that come with metadata for discovery.
20
21mod installer;
22mod parser;
23mod types;
24
25pub use types::{
26    ArgumentDef, HostRequirementsCheck, InstallSource, McpServerConfig, Program,
27    ProgramHostRequirements, ProgramMeta, ProgramState, ToolDef,
28};
29
30use std::collections::HashMap;
31use std::fs;
32use std::path::{Path, PathBuf};
33
34use anyhow::{Context, Result};
35use tokio::sync::RwLock;
36
37use crate::host_tools::HostToolValidator;
38
39use installer::copy_dir_all;
40
41/// Program manager — handles installation, uninstallation, and discovery
42pub struct ProgramManager {
43    /// Directory where programs are installed
44    programs_dir: PathBuf,
45    /// In-memory cache of installed programs
46    installed: RwLock<HashMap<String, Program>>,
47}
48
49impl ProgramManager {
50    /// Create a new program manager
51    pub fn new(programs_dir: PathBuf) -> Self {
52        Self {
53            programs_dir,
54            installed: RwLock::new(HashMap::new()),
55        }
56    }
57
58    /// Get the programs directory path
59    pub fn programs_dir(&self) -> &Path {
60        &self.programs_dir
61    }
62
63    /// Initialize the program manager, loading all installed programs
64    pub async fn init(&self) -> Result<()> {
65        self.load_all().await
66    }
67
68    /// Load all programs from the programs directory.
69    /// If the directory is empty, bootstrap from the `.programs/` directory in the oxios repo root.
70    async fn load_all(&self) -> Result<()> {
71        if !self.programs_dir.exists() {
72            fs::create_dir_all(&self.programs_dir)?;
73        }
74
75        // Count installed programs
76        let count = fs::read_dir(&self.programs_dir)?
77            .filter_map(|e| e.ok())
78            .filter(|e| e.path().is_dir())
79            .count();
80
81        // Bootstrap from `.programs/` (oxios repo root) if directory is empty.
82        // This allows `.programs/` in the repo to serve as the default program source.
83        if count == 0 {
84            Self::bootstrap_defaults(&self.programs_dir).await?;
85        }
86
87        let mut installed = self.installed.write().await;
88        for entry in fs::read_dir(&self.programs_dir)? {
89            let entry = entry?;
90            let path = entry.path();
91            if path.is_dir() {
92                if let Ok(program) = self.load_program(&path) {
93                    installed.insert(program.meta.name.clone(), program);
94                }
95            }
96        }
97
98        Ok(())
99    }
100
101    /// Bootstrap default programs from the `.programs/` directory in the oxios repo root.
102    /// This copies (not symlinks) so the programs directory is self-contained.
103    async fn bootstrap_defaults(target_dir: &Path) -> Result<()> {
104        let source_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".programs");
105        if !source_dir.exists() {
106            tracing::info!("No .programs/ directory found, skipping bootstrap");
107            return Ok(());
108        }
109
110        tracing::info!(source = %source_dir.display(), "Bootstrapping default programs");
111
112        for entry in fs::read_dir(&source_dir)? {
113            let entry = entry?;
114            let src = entry.path();
115            if src.is_dir() {
116                let name = match src.file_name().map(|n| n.to_string_lossy().into_owned()) {
117                    Some(n) if !n.is_empty() => n,
118                    _ => continue,
119                };
120                let dest = target_dir.join(&*name);
121
122                // Only copy if not already present
123                if !dest.exists() {
124                    copy_dir_all(&src, &dest)?;
125                    tracing::info!(program = %name, "Bootstrapped default program");
126                }
127            }
128        }
129
130        Ok(())
131    }
132
133    /// Load a single program from a directory
134    fn load_program(&self, path: &Path) -> Result<Program> {
135        let meta = ProgramMeta::load_from_dir(path)?;
136
137        let skill_path = path.join("SKILL.md");
138        let skill_content = if skill_path.exists() {
139            fs::read_to_string(&skill_path).unwrap_or_default()
140        } else {
141            String::new()
142        };
143
144        Ok(Program {
145            meta,
146            path: path.to_path_buf(),
147            skill_content,
148            enabled: self
149                .load_program_state(path)
150                .map(|s| s.enabled)
151                .unwrap_or(true),
152        })
153    }
154
155    /// List all installed programs
156    pub async fn list_programs(&self) -> Vec<Program> {
157        let installed = self.installed.read().await;
158        installed.values().cloned().collect()
159    }
160
161    /// List all enabled programs
162    pub async fn list_enabled(&self) -> Vec<Program> {
163        let installed = self.installed.read().await;
164        installed.values().filter(|p| p.enabled).cloned().collect()
165    }
166
167    /// Get a specific program by name
168    pub async fn get_program(&self, name: &str) -> Option<Program> {
169        let installed = self.installed.read().await;
170        installed.get(name).cloned()
171    }
172
173    /// Install a program from a directory
174    pub async fn install(&self, source_path: &Path) -> Result<Program> {
175        // Load the source program
176        let source_meta = ProgramMeta::load_from_dir(source_path)?;
177        let source_skill = source_path.join("SKILL.md");
178        let skill_content = if source_skill.exists() {
179            fs::read_to_string(&source_skill)?
180        } else {
181            String::new()
182        };
183
184        // Create the destination path
185        let dest_path = self.programs_dir.join(&source_meta.name);
186        if dest_path.exists() {
187            anyhow::bail!("Program '{}' is already installed", source_meta.name);
188        }
189
190        // Copy the directory recursively
191        copy_dir_all(source_path, &dest_path)?;
192
193        // Create initial state file
194        let state = ProgramState::new();
195        let state_json = serde_json::to_string_pretty(&state)?;
196        fs::write(dest_path.join("state.json"), state_json)?;
197
198        // Create the program entry
199        let program = Program {
200            meta: source_meta,
201            path: dest_path,
202            skill_content,
203            enabled: true,
204        };
205
206        // Add to the installed map
207        let mut installed = self.installed.write().await;
208        installed.insert(program.meta.name.clone(), program.clone());
209
210        Ok(program)
211    }
212
213    /// Install a program from an [InstallSource].
214    pub async fn install_from(&self, source: InstallSource) -> Result<Program> {
215        match source {
216            InstallSource::Local(path) => self.install_from_local(&path).await,
217            InstallSource::Git { url, branch } => {
218                self.install_from_git(&url, branch.as_deref()).await
219            }
220            InstallSource::Tarball { url } => self.install_from_tarball(&url).await,
221        }
222    }
223
224    /// Install a program from a local directory path.
225    async fn install_from_local(&self, source_path: &Path) -> Result<Program> {
226        self.install(source_path).await
227    }
228
229    /// Install a program by cloning a git repository, then installing the result.
230    async fn install_from_git(&self, url: &str, branch: Option<&str>) -> Result<Program> {
231        // Create a temporary directory for the clone.
232        let temp_dir = tempfile::tempdir().map_err(|e| anyhow::anyhow!("tempfile: {}", e))?;
233        let clone_path = temp_dir.path();
234
235        // Clone the repository.
236        tracing::info!(url, branch = ?branch, "Cloning git repository");
237        let mut cmd = tokio::process::Command::new("git");
238        cmd.arg("clone");
239        if let Some(branch) = branch {
240            cmd.arg("--branch").arg(branch);
241        }
242        cmd.arg("--depth").arg("1");
243        cmd.arg(url);
244        cmd.arg(clone_path);
245
246        let output = cmd
247            .output()
248            .await
249            .with_context(|| format!("Failed to run git clone for '{}'", url))?;
250
251        if !output.status.success() {
252            anyhow::bail!(
253                "git clone failed (exit {}): {}",
254                output.status,
255                String::from_utf8_lossy(&output.stderr)
256            );
257        }
258
259        // Find the cloned program directory (single top-level directory expected).
260        let entries: Vec<_> = std::fs::read_dir(clone_path)?
261            .filter_map(|e| e.ok())
262            .filter(|e| e.path().is_dir())
263            .collect();
264
265        let program_dir = if entries.len() == 1 {
266            entries
267                .into_iter()
268                .next()
269                .map(|e| e.path())
270                .unwrap_or_else(|| clone_path.to_path_buf())
271        } else {
272            clone_path.to_path_buf()
273        };
274
275        let program = self.install(&program_dir).await?;
276
277        tracing::info!(name = %program.meta.name, "Program installed from git");
278        Ok(program)
279    }
280
281    /// Install a program by downloading and extracting a tarball.
282    async fn install_from_tarball(&self, url: &str) -> Result<Program> {
283        // Create a temporary directory for download and extraction.
284        let temp_dir = tempfile::tempdir().map_err(|e| anyhow::anyhow!("tempfile: {}", e))?;
285        let download_path = temp_dir.path().join("program.tar.gz");
286        let extract_base = temp_dir.path().join("extracted");
287
288        // Download the tarball with curl.
289        tracing::info!(url, "Downloading tarball");
290        let curl = tokio::process::Command::new("curl")
291            .arg("-fsSL")
292            .arg("-o")
293            .arg(&download_path)
294            .arg(url)
295            .output()
296            .await
297            .with_context(|| format!("Failed to run curl for '{}'", url))?;
298
299        if !curl.status.success() {
300            anyhow::bail!(
301                "curl failed (exit {}): {}",
302                curl.status,
303                String::from_utf8_lossy(&curl.stderr)
304            );
305        }
306
307        // Extract the tarball.
308        tracing::info!("Extracting tarball");
309        let tar = tokio::process::Command::new("tar")
310            .arg("-xzf")
311            .arg(&download_path)
312            .arg("-C")
313            .arg(&extract_base)
314            .output()
315            .await
316            .with_context(|| "Failed to run tar to extract tarball")?;
317
318        if !tar.status.success() {
319            anyhow::bail!(
320                "tar extraction failed (exit {}): {}",
321                tar.status,
322                String::from_utf8_lossy(&tar.stderr)
323            );
324        }
325
326        // Find the extracted program directory.
327        let entries: Vec<_> = std::fs::read_dir(&extract_base)?
328            .filter_map(|e| e.ok())
329            .filter(|e| e.path().is_dir())
330            .collect();
331
332        let program_dir = if entries.len() == 1 {
333            entries
334                .into_iter()
335                .next()
336                .map(|e| e.path())
337                .unwrap_or_else(|| extract_base.to_path_buf())
338        } else {
339            extract_base.to_path_buf()
340        };
341
342        let program = self.install(&program_dir).await?;
343
344        tracing::info!(name = %program.meta.name, "Program installed from tarball");
345        Ok(program)
346    }
347
348    /// Uninstall a program
349    pub async fn uninstall(&self, name: &str) -> Result<()> {
350        let mut installed = self.installed.write().await;
351
352        let program = installed
353            .remove(name)
354            .ok_or_else(|| anyhow::anyhow!("Program '{}' not found", name))?;
355
356        // Remove the directory
357        if program.path.exists() {
358            fs::remove_dir_all(&program.path)?;
359        }
360
361        Ok(())
362    }
363
364    /// Enable or disable a program (persisted across restarts).
365    pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> {
366        let mut installed = self.installed.write().await;
367
368        let program = installed
369            .get_mut(name)
370            .ok_or_else(|| anyhow::anyhow!("Program '{}' not found", name))?;
371
372        program.enabled = enabled;
373
374        // Persist to state.json
375        self.persist_state(&program.path, enabled)?;
376
377        tracing::info!(name, enabled, "Program enabled state persisted");
378        Ok(())
379    }
380
381    /// Check if host requirements are met for a program
382    pub async fn check_host_requirements(&self, name: &str) -> Result<HostRequirementsCheck> {
383        let installed = self.installed.read().await;
384
385        let program = installed
386            .get(name)
387            .ok_or_else(|| anyhow::anyhow!("Program '{}' not found", name))?;
388
389        let validator = HostToolValidator::new(
390            program.meta.host_requirements.required.clone(),
391            program.meta.host_requirements.optional.clone(),
392        );
393
394        let missing_required = validator.validate_required();
395        let optional_status = validator.check_optional();
396
397        Ok(HostRequirementsCheck {
398            program_name: name.to_string(),
399            missing_required,
400            optional_available: optional_status,
401        })
402    }
403
404    /// Get tool schemas for all enabled programs
405    pub async fn all_tool_schemas(&self) -> Vec<ToolDef> {
406        let installed = self.installed.read().await;
407        installed
408            .values()
409            .filter(|p| p.enabled)
410            .flat_map(|p| p.meta.tools.clone())
411            .collect()
412    }
413
414    /// Get skill content for a specific program
415    pub async fn get_skill_content(&self, name: &str) -> Option<String> {
416        let installed = self.installed.read().await;
417        installed.get(name).map(|p| p.skill_content.clone())
418    }
419
420    /// Upgrade an existing program, or install if not present.
421    ///
422    /// Compares versions using SemVer. If the new version is the same,
423    /// returns the existing program (no-op). If different, performs
424    /// atomic replace: uninstall old → install new, preserving the
425    /// enabled state.
426    pub async fn upgrade(&self, source_path: &Path) -> Result<Program> {
427        let source_meta = ProgramMeta::load_from_dir(source_path)?;
428
429        // Check if already installed
430        let existing = self.get_program(&source_meta.name).await;
431
432        if let Some(ref old) = existing {
433            let cmp = compare_versions(&source_meta.version, &old.meta.version);
434            match cmp {
435                VersionCmp::Equal => {
436                    tracing::info!(
437                        name = %source_meta.name,
438                        version = %source_meta.version,
439                        "Program already at same version — no upgrade needed"
440                    );
441                    return Ok(old.clone());
442                }
443                VersionCmp::Older => {
444                    tracing::warn!(
445                        name = %source_meta.name,
446                        old = %old.meta.version,
447                        new = %source_meta.version,
448                        "Downgrade requested — proceeding"
449                    );
450                }
451                VersionCmp::Newer => {
452                    tracing::info!(
453                        name = %source_meta.name,
454                        old = %old.meta.version,
455                        new = %source_meta.version,
456                        "Upgrading program"
457                    );
458                }
459            }
460
461            // Preserve enabled state across upgrade
462            let was_enabled = old.enabled;
463
464            // Atomic upgrade: copy to temp, validate, then swap
465            let target_path = self.programs_dir.join(&source_meta.name);
466            let temp_path = self
467                .programs_dir
468                .join(format!(".tmp-upgrade-{}", source_meta.name));
469
470            // Clean up any leftover temp directory from a failed previous upgrade
471            if temp_path.exists() {
472                let _ = fs::remove_dir_all(&temp_path);
473            }
474
475            // Step 1: Copy source to temp directory
476            if let Err(e) = copy_dir_all(source_path, &temp_path) {
477                let _ = fs::remove_dir_all(&temp_path);
478                return Err(e.context("Failed to copy program to temp directory for upgrade"));
479            }
480
481            // Step 2: Parse and validate the new program from the temp directory
482            let new_meta = match ProgramMeta::load_from_dir(&temp_path) {
483                Ok(meta) => meta,
484                Err(e) => {
485                    let _ = fs::remove_dir_all(&temp_path);
486                    return Err(e.context("Failed to validate upgraded program"));
487                }
488            };
489
490            let skill_path = temp_path.join("SKILL.md");
491            let skill_content = if skill_path.exists() {
492                fs::read_to_string(&skill_path).unwrap_or_default()
493            } else {
494                String::new()
495            };
496
497            // Step 3: Create state file in temp directory
498            let state = ProgramState::new().with_enabled(was_enabled);
499            let state_json = serde_json::to_string_pretty(&state)?;
500            fs::write(temp_path.join("state.json"), state_json)?;
501
502            // Step 4: Remove old directory and rename temp to target
503            if target_path.exists() {
504                fs::remove_dir_all(&target_path)?;
505            }
506            fs::rename(&temp_path, &target_path)?;
507
508            // Step 5: Update in-memory cache
509            let program = Program {
510                meta: new_meta,
511                path: target_path,
512                skill_content,
513                enabled: was_enabled,
514            };
515
516            let mut installed = self.installed.write().await;
517            installed.insert(program.meta.name.clone(), program.clone());
518
519            tracing::info!(
520                name = %program.meta.name,
521                version = %program.meta.version,
522                enabled = program.enabled,
523                "Program upgraded"
524            );
525
526            Ok(program)
527        } else {
528            // Not installed — just install
529            tracing::info!(
530                name = %source_meta.name,
531                "Program not installed — performing fresh install"
532            );
533            self.install(source_path).await
534        }
535    }
536
537    // ── State Persistence Helpers ──
538
539    /// Load program state from state.json in the program directory.
540    fn load_program_state(&self, path: &Path) -> Result<ProgramState> {
541        let state_path = path.join("state.json");
542        if !state_path.exists() {
543            return Ok(ProgramState::default());
544        }
545        let json = fs::read_to_string(&state_path)?;
546        let state: ProgramState = serde_json::from_str(&json)?;
547        Ok(state)
548    }
549
550    /// Persist enabled state to state.json in the program directory.
551    fn persist_state(&self, program_path: &Path, enabled: bool) -> Result<()> {
552        let mut state = self.load_program_state(program_path).unwrap_or_default();
553        state.enabled = enabled;
554        state.last_modified = chrono::Utc::now().to_rfc3339();
555        let state_json = serde_json::to_string_pretty(&state)?;
556        fs::write(program_path.join("state.json"), state_json)?;
557        Ok(())
558    }
559}
560
561// ── Version Comparison ─────────────────────────────────────────────────────────
562
563/// Result of comparing two SemVer version strings.
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565enum VersionCmp {
566    /// Versions are identical.
567    Equal,
568    /// First version is newer.
569    Newer,
570    /// First version is older.
571    Older,
572}
573
574/// Compare two SemVer version strings (major.minor.patch).
575///
576/// Handles `v` prefix and missing components gracefully.
577fn compare_versions(a: &str, b: &str) -> VersionCmp {
578    let parse = |v: &str| -> Vec<u32> {
579        v.strip_prefix('v')
580            .unwrap_or(v)
581            .split('.')
582            .filter_map(|s| s.parse().ok())
583            .collect()
584    };
585    let va = parse(a);
586    let vb = parse(b);
587    for i in 0..va.len().max(vb.len()) {
588        let na = va.get(i).unwrap_or(&0);
589        let nb = vb.get(i).unwrap_or(&0);
590        match na.cmp(nb) {
591            std::cmp::Ordering::Greater => return VersionCmp::Newer,
592            std::cmp::Ordering::Less => return VersionCmp::Older,
593            std::cmp::Ordering::Equal => continue,
594        }
595    }
596    VersionCmp::Equal
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use std::fs;
603
604    // --- ProgramMeta tests ---
605
606    #[test]
607    fn test_program_meta_load_minimal() {
608        let temp_dir = tempfile::tempdir().unwrap();
609        let program_dir = temp_dir.path();
610
611        // Create a minimal program.toml
612        let toml_content = r#"
613[program]
614name = "test-program"
615version = "1.0.0"
616description = "A test program"
617author = "Test Author"
618
619[tools]
620my_tool = { description = "A test tool" }
621"#;
622
623        fs::write(program_dir.join("program.toml"), toml_content).unwrap();
624        fs::write(
625            program_dir.join("SKILL.md"),
626            "# Test Program\n\nThis is a test.",
627        )
628        .unwrap();
629
630        let meta = ProgramMeta::load_from_dir(program_dir).unwrap();
631
632        assert_eq!(meta.name, "test-program");
633        assert_eq!(meta.version, "1.0.0");
634        assert_eq!(meta.description, "A test program");
635        assert_eq!(meta.author, "Test Author");
636        assert_eq!(meta.tools.len(), 1);
637        assert_eq!(meta.tools[0].name, "my_tool");
638        assert_eq!(meta.tools[0].description, "A test tool");
639        assert!(meta.tools[0].arguments.is_empty());
640    }
641
642    #[test]
643    fn test_program_meta_with_tools_and_args() {
644        let temp_dir = tempfile::tempdir().unwrap();
645        let program_dir = temp_dir.path();
646
647        let toml_content = r#"
648[program]
649name = "rich-program"
650version = "2.0.0"
651description = "A program with rich tools"
652author = "Author"
653
654[tools.greet]
655description = "Greets a user"
656arguments = [
657    { name = "name", description = "User name", required = true },
658    { name = "loud", description = "Shout", required = false, default = "false" }
659]
660
661[tools.farewell]
662description = "Says goodbye"
663arguments = []
664"#;
665
666        fs::write(program_dir.join("program.toml"), toml_content).unwrap();
667
668        let meta = ProgramMeta::load_from_dir(program_dir).unwrap();
669
670        assert_eq!(meta.tools.len(), 2);
671
672        // Find greet by name (order may vary).
673        let greet = meta
674            .tools
675            .iter()
676            .find(|t| t.name == "greet")
677            .expect("greet tool");
678        assert_eq!(greet.arguments.len(), 2);
679
680        // Verify arguments exist and have correct structure.
681        let name_arg = greet
682            .arguments
683            .iter()
684            .find(|a| a.name == "name")
685            .expect("name arg");
686        assert!(name_arg.required, "name should be required");
687        assert_eq!(name_arg.description, "User name");
688
689        let loud_arg = greet
690            .arguments
691            .iter()
692            .find(|a| a.name == "loud")
693            .expect("loud arg");
694        assert_eq!(loud_arg.default, Some("false".to_string()));
695    }
696
697    #[test]
698    fn test_program_meta_with_dependencies() {
699        let temp_dir = tempfile::tempdir().unwrap();
700        let program_dir = temp_dir.path();
701
702        let toml_content = r#"
703[program]
704name = "test-with-deps"
705version = "1.0.0"
706description = "A test program with dependencies"
707author = "Test Author"
708
709[host_requirements]
710required = ["git", "gh"]
711optional = ["jq", "curl"]
712"#;
713
714        fs::write(program_dir.join("program.toml"), toml_content).unwrap();
715
716        let meta = ProgramMeta::load_from_dir(program_dir).unwrap();
717
718        assert_eq!(meta.host_requirements.required, vec!["git", "gh"]);
719        assert_eq!(meta.host_requirements.optional, vec!["jq", "curl"]);
720    }
721
722    #[test]
723    fn test_program_meta_missing_file() {
724        let temp_dir = tempfile::tempdir().unwrap();
725        let program_dir = temp_dir.path();
726
727        // No program.toml — should error.
728        let result = ProgramMeta::load_from_dir(program_dir);
729        assert!(result.is_err());
730    }
731
732    #[test]
733    fn test_program_meta_empty_optional_sections() {
734        let temp_dir = tempfile::tempdir().unwrap();
735        let program_dir = temp_dir.path();
736
737        let toml_content = r#"
738[program]
739name = "minimal"
740version = "1.0.0"
741description = "Minimal"
742author = "X"
743"#;
744
745        fs::write(program_dir.join("program.toml"), toml_content).unwrap();
746
747        let meta = ProgramMeta::load_from_dir(program_dir).unwrap();
748
749        assert!(meta.tools.is_empty());
750        assert!(meta.host_requirements.required.is_empty());
751        assert!(meta.host_requirements.optional.is_empty());
752        assert!(meta.dependencies.is_empty());
753    }
754
755    #[test]
756    fn test_requires_tools_parsed() {
757        let temp_dir = tempfile::tempdir().unwrap();
758        let program_dir = temp_dir.path();
759
760        let toml_content = r#"
761[program]
762name = "needs-tools"
763version = "1.0.0"
764description = "A program that requires tools"
765author = "Test"
766
767[requires_tools]
768names = ["read", "exec"]
769"#;
770
771        fs::write(program_dir.join("program.toml"), toml_content).unwrap();
772
773        let meta = ProgramMeta::load_from_dir(program_dir).unwrap();
774
775        assert_eq!(meta.dependencies, vec!["read", "exec"]);
776    }
777
778    #[test]
779    fn test_requires_tools_empty() {
780        let temp_dir = tempfile::tempdir().unwrap();
781        let program_dir = temp_dir.path();
782
783        let toml_content = r#"
784[program]
785name = "no-reqs"
786version = "1.0.0"
787description = "No requirements"
788author = "Test"
789
790[requires_tools]
791names = []
792"#;
793
794        fs::write(program_dir.join("program.toml"), toml_content).unwrap();
795
796        let meta = ProgramMeta::load_from_dir(program_dir).unwrap();
797
798        assert!(meta.dependencies.is_empty());
799    }
800
801    #[test]
802    fn test_requires_tools_single() {
803        let temp_dir = tempfile::tempdir().unwrap();
804        let program_dir = temp_dir.path();
805
806        let toml_content = r#"
807[program]
808name = "single-req"
809version = "1.0.0"
810description = "Single requirement"
811author = "Test"
812
813[requires_tools]
814names = ["grep"]
815"#;
816
817        fs::write(program_dir.join("program.toml"), toml_content).unwrap();
818
819        let meta = ProgramMeta::load_from_dir(program_dir).unwrap();
820
821        assert_eq!(meta.dependencies, vec!["grep"]);
822    }
823
824    // --- ProgramManager tests ---
825
826    #[tokio::test]
827    async fn test_program_manager_init_creates_dir() {
828        let temp_dir = tempfile::tempdir().unwrap();
829        let programs_dir = temp_dir.path().join("programs");
830
831        let manager = ProgramManager::new(programs_dir.clone());
832        manager.init().await.unwrap();
833
834        assert!(programs_dir.exists());
835    }
836
837    #[tokio::test]
838    async fn test_list_programs_empty() {
839        let temp_dir = tempfile::tempdir().unwrap();
840        let programs_dir = temp_dir.path().join("programs");
841
842        let manager = ProgramManager::new(programs_dir.clone());
843        manager.init().await.unwrap();
844
845        let programs = manager.list_programs().await;
846        assert!(programs.is_empty());
847    }
848
849    #[tokio::test]
850    async fn test_get_program_nonexistent() {
851        let temp_dir = tempfile::tempdir().unwrap();
852        let manager = ProgramManager::new(temp_dir.path().join("programs"));
853        manager.init().await.unwrap();
854
855        assert!(manager.get_program("nonexistent").await.is_none());
856    }
857
858    #[tokio::test]
859    async fn test_install_program() {
860        let temp_dir = tempfile::tempdir().unwrap();
861        let programs_dir = temp_dir.path().join("programs");
862        let source_dir = temp_dir.path().join("source-program");
863
864        // Create source program.
865        fs::create_dir_all(&source_dir).unwrap();
866        let toml = r#"
867[program]
868name = "my-program"
869version = "1.0.0"
870description = "My program"
871author = "Test"
872
873[tools.hello]
874description = "Says hello"
875"#;
876        fs::write(source_dir.join("program.toml"), toml).unwrap();
877        fs::write(source_dir.join("SKILL.md"), "# My Program\n\nDoes things.").unwrap();
878
879        let manager = ProgramManager::new(programs_dir.clone());
880        manager.init().await.unwrap();
881
882        let installed = manager.install(&source_dir).await.unwrap();
883
884        assert_eq!(installed.meta.name, "my-program");
885        assert_eq!(installed.meta.version, "1.0.0");
886        assert!(installed.enabled);
887        assert!(!installed.skill_content.is_empty());
888
889        // Verify it's listed.
890        let programs = manager.list_programs().await;
891        assert_eq!(programs.len(), 1);
892        assert_eq!(programs[0].meta.name, "my-program");
893
894        // Verify directory exists.
895        assert!(programs_dir.join("my-program").exists());
896    }
897
898    #[tokio::test]
899    async fn test_install_duplicate_fails() {
900        let temp_dir = tempfile::tempdir().unwrap();
901        let programs_dir = temp_dir.path().join("programs");
902        let source1 = temp_dir.path().join("src1");
903        let source2 = temp_dir.path().join("src2");
904
905        for src in [&source1, &source2] {
906            fs::create_dir_all(src).unwrap();
907            let toml = r#"
908[program]
909name = "dup"
910version = "1.0.0"
911description = "X"
912author = "X"
913"#;
914            fs::write(src.join("program.toml"), toml).unwrap();
915        }
916
917        let manager = ProgramManager::new(programs_dir.clone());
918        manager.init().await.unwrap();
919
920        manager.install(&source1).await.unwrap();
921        let result = manager.install(&source2).await;
922        assert!(result.is_err());
923        assert!(result
924            .unwrap_err()
925            .to_string()
926            .contains("already installed"));
927    }
928
929    #[tokio::test]
930    async fn test_uninstall_program() {
931        let temp_dir = tempfile::tempdir().unwrap();
932        let programs_dir = temp_dir.path().join("programs");
933        let source = temp_dir.path().join("to-uninstall");
934
935        fs::create_dir_all(&source).unwrap();
936        fs::write(
937            source.join("program.toml"),
938            r#"
939[program]
940name = "removable"
941version = "1.0.0"
942description = "X"
943author = "X"
944"#,
945        )
946        .unwrap();
947
948        let manager = ProgramManager::new(programs_dir.clone());
949        manager.init().await.unwrap();
950
951        manager.install(&source).await.unwrap();
952        assert!(manager.get_program("removable").await.is_some());
953
954        manager.uninstall("removable").await.unwrap();
955        assert!(manager.get_program("removable").await.is_none());
956
957        // Directory should be gone.
958        assert!(!programs_dir.join("removable").exists());
959    }
960
961    #[tokio::test]
962    async fn test_uninstall_nonexistent_fails() {
963        let temp_dir = tempfile::tempdir().unwrap();
964        let manager = ProgramManager::new(temp_dir.path().join("programs"));
965        manager.init().await.unwrap();
966
967        let result = manager.uninstall("nonexistent").await;
968        assert!(result.is_err());
969    }
970
971    #[tokio::test]
972    async fn test_set_enabled() {
973        let temp_dir = tempfile::tempdir().unwrap();
974        let programs_dir = temp_dir.path().join("programs");
975        let source = temp_dir.path().join("toggle-me");
976
977        fs::create_dir_all(&source).unwrap();
978        fs::write(
979            source.join("program.toml"),
980            r#"
981[program]
982name = "toggle-me"
983version = "1.0.0"
984description = "X"
985author = "X"
986"#,
987        )
988        .unwrap();
989
990        let manager = ProgramManager::new(programs_dir.clone());
991        manager.init().await.unwrap();
992
993        manager.install(&source).await.unwrap();
994
995        let prog = manager.get_program("toggle-me").await.unwrap();
996        assert!(prog.enabled);
997
998        manager.set_enabled("toggle-me", false).await.unwrap();
999        let prog = manager.get_program("toggle-me").await.unwrap();
1000        assert!(!prog.enabled);
1001
1002        manager.set_enabled("toggle-me", true).await.unwrap();
1003        let prog = manager.get_program("toggle-me").await.unwrap();
1004        assert!(prog.enabled);
1005    }
1006
1007    #[tokio::test]
1008    async fn test_all_tool_schemas() {
1009        let temp_dir = tempfile::tempdir().unwrap();
1010        let programs_dir = temp_dir.path().join("programs");
1011
1012        // Install two programs with tools.
1013        for (name, tool_name) in [("prog-a", "tool-a"), ("prog-b", "tool-b")] {
1014            let src = temp_dir.path().join(name);
1015            fs::create_dir_all(&src).unwrap();
1016            let toml = format!(
1017                r#"
1018[program]
1019name = "{}"
1020version = "1.0.0"
1021description = "X"
1022author = "X"
1023
1024[tools.{}]
1025description = "A tool"
1026"#,
1027                name, tool_name
1028            );
1029            fs::write(src.join("program.toml"), toml).unwrap();
1030        }
1031
1032        let manager = ProgramManager::new(programs_dir.clone());
1033        manager.init().await.unwrap();
1034
1035        // Install prog-a (enabled by default), disable prog-b.
1036        let src_a = temp_dir.path().join("prog-a");
1037        let src_b = temp_dir.path().join("prog-b");
1038        manager.install(&src_a).await.unwrap();
1039        manager.install(&src_b).await.unwrap();
1040        manager.set_enabled("prog-b", false).await.unwrap();
1041
1042        let schemas = manager.all_tool_schemas().await;
1043        assert_eq!(schemas.len(), 1);
1044        assert_eq!(schemas[0].name, "tool-a");
1045    }
1046
1047    #[tokio::test]
1048    async fn test_get_skill_content() {
1049        let temp_dir = tempfile::tempdir().unwrap();
1050        let programs_dir = temp_dir.path().join("programs");
1051        let source = temp_dir.path().join("skill-test");
1052
1053        fs::create_dir_all(&source).unwrap();
1054        fs::write(
1055            source.join("program.toml"),
1056            r#"
1057[program]
1058name = "skill-test"
1059version = "1.0.0"
1060description = "X"
1061author = "X"
1062"#,
1063        )
1064        .unwrap();
1065        fs::write(
1066            source.join("SKILL.md"),
1067            "# Skill Test\n\nUse this program like so.",
1068        )
1069        .unwrap();
1070
1071        let manager = ProgramManager::new(programs_dir.clone());
1072        manager.init().await.unwrap();
1073
1074        manager.install(&source).await.unwrap();
1075
1076        let content = manager.get_skill_content("skill-test").await;
1077        assert!(content.is_some());
1078        assert!(content.unwrap().contains("Skill Test"));
1079    }
1080
1081    #[tokio::test]
1082    async fn test_check_host_requirements() {
1083        let temp_dir = tempfile::tempdir().unwrap();
1084        let programs_dir = temp_dir.path().join("programs");
1085        let source = temp_dir.path().join("req-check");
1086
1087        fs::create_dir_all(&source).unwrap();
1088        fs::write(
1089            source.join("program.toml"),
1090            r#"
1091[program]
1092name = "req-check"
1093version = "1.0.0"
1094description = "X"
1095author = "X"
1096
1097[host_requirements]
1098required = ["git"]
1099optional = ["echo", "nonexistent-tool-xyz"]
1100"#,
1101        )
1102        .unwrap();
1103
1104        let manager = ProgramManager::new(programs_dir.clone());
1105        manager.init().await.unwrap();
1106
1107        manager.install(&source).await.unwrap();
1108
1109        let check = manager.check_host_requirements("req-check").await.unwrap();
1110        assert_eq!(check.program_name, "req-check");
1111        // git should be available on most systems.
1112        assert!(check.missing_required.is_empty());
1113        // Optional tools status.
1114        assert!(check.optional_available["echo"]);
1115        assert!(!check.optional_available["nonexistent-tool-xyz"]);
1116    }
1117
1118    #[tokio::test]
1119    async fn test_check_host_requirements_program_not_found() {
1120        let temp_dir = tempfile::tempdir().unwrap();
1121        let manager = ProgramManager::new(temp_dir.path().join("programs"));
1122        manager.init().await.unwrap();
1123
1124        let result = manager.check_host_requirements("ghost").await;
1125        assert!(result.is_err());
1126    }
1127
1128    #[test]
1129    fn test_copy_dir_all() {
1130        let temp_dir = tempfile::tempdir().unwrap();
1131        let src = temp_dir.path().join("src");
1132        let dst = temp_dir.path().join("dst");
1133
1134        fs::create_dir_all(src.join("subdir")).unwrap();
1135        fs::write(src.join("file.txt"), "content").unwrap();
1136        fs::write(src.join("subdir").join("nested.txt"), "nested").unwrap();
1137
1138        copy_dir_all(&src, &dst).unwrap();
1139
1140        assert!(dst.join("file.txt").exists());
1141        assert!(dst.join("subdir").join("nested.txt").exists());
1142        assert_eq!(fs::read_to_string(dst.join("file.txt")).unwrap(), "content");
1143    }
1144
1145    // ── Version Comparison Tests ──
1146
1147    #[test]
1148    fn test_compare_versions_equal() {
1149        assert_eq!(compare_versions("1.0.0", "1.0.0"), VersionCmp::Equal);
1150        assert_eq!(compare_versions("2.3.4", "2.3.4"), VersionCmp::Equal);
1151    }
1152
1153    #[test]
1154    fn test_compare_versions_newer() {
1155        assert_eq!(compare_versions("2.0.0", "1.0.0"), VersionCmp::Newer);
1156        assert_eq!(compare_versions("1.1.0", "1.0.0"), VersionCmp::Newer);
1157        assert_eq!(compare_versions("1.0.1", "1.0.0"), VersionCmp::Newer);
1158        assert_eq!(compare_versions("1.0.0", "0.9.9"), VersionCmp::Newer);
1159    }
1160
1161    #[test]
1162    fn test_compare_versions_older() {
1163        assert_eq!(compare_versions("1.0.0", "2.0.0"), VersionCmp::Older);
1164        assert_eq!(compare_versions("1.0.0", "1.1.0"), VersionCmp::Older);
1165    }
1166
1167    #[test]
1168    fn test_compare_versions_with_v_prefix() {
1169        assert_eq!(compare_versions("v1.0.0", "1.0.0"), VersionCmp::Equal);
1170        assert_eq!(compare_versions("v2.0.0", "v1.0.0"), VersionCmp::Newer);
1171    }
1172
1173    #[test]
1174    fn test_compare_versions_missing_components() {
1175        assert_eq!(compare_versions("1.0", "1.0.0"), VersionCmp::Equal);
1176        assert_eq!(compare_versions("2", "1.0.0"), VersionCmp::Newer);
1177    }
1178
1179    // ── State Persistence Tests ──
1180
1181    #[test]
1182    fn test_program_state_default() {
1183        let state = ProgramState::default();
1184        assert!(state.enabled);
1185        assert!(!state.installed_at.is_empty());
1186        assert!(!state.last_modified.is_empty());
1187    }
1188
1189    #[test]
1190    fn test_program_state_with_enabled() {
1191        let state = ProgramState::new().with_enabled(false);
1192        assert!(!state.enabled);
1193    }
1194
1195    #[tokio::test]
1196    async fn test_state_json_created_on_install() {
1197        let temp_dir = tempfile::tempdir().unwrap();
1198        let programs_dir = temp_dir.path().join("programs");
1199        let source_dir = temp_dir.path().join("source");
1200        fs::create_dir_all(&source_dir).unwrap();
1201        fs::write(
1202            source_dir.join("program.toml"),
1203            r#"
1204[program]
1205name = "state-test"
1206version = "1.0.0"
1207description = "Test"
1208author = "Test"
1209"#,
1210        )
1211        .unwrap();
1212        fs::write(source_dir.join("SKILL.md"), "# Test").unwrap();
1213
1214        let manager = ProgramManager::new(programs_dir);
1215        manager.init().await.unwrap();
1216        let program = manager.install(&source_dir).await.unwrap();
1217
1218        // state.json should exist
1219        let state_path = program.path.join("state.json");
1220        assert!(state_path.exists());
1221
1222        let state: ProgramState =
1223            serde_json::from_str(&fs::read_to_string(&state_path).unwrap()).unwrap();
1224        assert!(state.enabled);
1225    }
1226
1227    #[tokio::test]
1228    async fn test_set_enabled_persists() {
1229        let temp_dir = tempfile::tempdir().unwrap();
1230        let programs_dir = temp_dir.path().join("programs");
1231        let source_dir = temp_dir.path().join("source");
1232        fs::create_dir_all(&source_dir).unwrap();
1233        fs::write(
1234            source_dir.join("program.toml"),
1235            r#"
1236[program]
1237name = "toggle-test"
1238version = "1.0.0"
1239description = "Test"
1240author = "Test"
1241"#,
1242        )
1243        .unwrap();
1244        fs::write(source_dir.join("SKILL.md"), "# Test").unwrap();
1245
1246        let manager = ProgramManager::new(programs_dir);
1247        manager.init().await.unwrap();
1248        let program = manager.install(&source_dir).await.unwrap();
1249
1250        // Disable
1251        manager.set_enabled("toggle-test", false).await.unwrap();
1252
1253        // Verify state.json reflects disabled
1254        let state: ProgramState =
1255            serde_json::from_str(&fs::read_to_string(program.path.join("state.json")).unwrap())
1256                .unwrap();
1257        assert!(!state.enabled);
1258
1259        // Re-enable
1260        manager.set_enabled("toggle-test", true).await.unwrap();
1261        let state: ProgramState =
1262            serde_json::from_str(&fs::read_to_string(program.path.join("state.json")).unwrap())
1263                .unwrap();
1264        assert!(state.enabled);
1265    }
1266
1267    #[tokio::test]
1268    async fn test_enabled_state_survives_reload() {
1269        let temp_dir = tempfile::tempdir().unwrap();
1270        let programs_dir = temp_dir.path().join("programs");
1271        let source_dir = temp_dir.path().join("source");
1272        fs::create_dir_all(&source_dir).unwrap();
1273        fs::write(
1274            source_dir.join("program.toml"),
1275            r#"
1276[program]
1277name = "persist-test"
1278version = "1.0.0"
1279description = "Test"
1280author = "Test"
1281"#,
1282        )
1283        .unwrap();
1284        fs::write(source_dir.join("SKILL.md"), "# Test").unwrap();
1285
1286        // First manager: install and disable
1287        let manager = ProgramManager::new(programs_dir.clone());
1288        manager.init().await.unwrap();
1289        manager.install(&source_dir).await.unwrap();
1290        manager.set_enabled("persist-test", false).await.unwrap();
1291        drop(manager);
1292
1293        // Second manager: reload from disk — state should be disabled
1294        let manager2 = ProgramManager::new(programs_dir);
1295        manager2.init().await.unwrap();
1296        let reloaded = manager2.get_program("persist-test").await.unwrap();
1297        assert!(!reloaded.enabled, "disabled state should survive restart");
1298    }
1299
1300    // ── Upgrade Tests ──
1301
1302    fn make_program_dir(parent: &Path, name: &str, version: &str) -> PathBuf {
1303        let dir = parent.join(name);
1304        fs::create_dir_all(&dir).unwrap();
1305        let toml = format!(
1306            "[program]\nname = \"{}\"\nversion = \"{}\"\ndescription = \"Test\"\nauthor = \"Test\"\n",
1307            name, version,
1308        );
1309        fs::write(dir.join("program.toml"), toml).unwrap();
1310        fs::write(dir.join("SKILL.md"), "# Test").unwrap();
1311        dir
1312    }
1313
1314    #[tokio::test]
1315    async fn test_upgrade_same_version_is_noop() {
1316        let temp_dir = tempfile::tempdir().unwrap();
1317        let programs_dir = temp_dir.path().join("programs");
1318
1319        let manager = ProgramManager::new(programs_dir);
1320        manager.init().await.unwrap();
1321
1322        // Install v1.0.0
1323        let v1_dir = make_program_dir(temp_dir.path(), "up-test", "1.0.0");
1324        manager.install(&v1_dir).await.unwrap();
1325
1326        // Upgrade to same v1.0.0 — should be no-op
1327        let v1_dir2 = make_program_dir(&temp_dir.path().join("v1copy"), "up-test", "1.0.0");
1328        let result = manager.upgrade(&v1_dir2).await.unwrap();
1329        assert_eq!(result.meta.version, "1.0.0");
1330    }
1331
1332    #[tokio::test]
1333    async fn test_upgrade_newer_version() {
1334        let temp_dir = tempfile::tempdir().unwrap();
1335        let programs_dir = temp_dir.path().join("programs");
1336
1337        let manager = ProgramManager::new(programs_dir);
1338        manager.init().await.unwrap();
1339
1340        // Install v1.0.0
1341        let v1_dir = make_program_dir(temp_dir.path(), "up-test", "1.0.0");
1342        manager.install(&v1_dir).await.unwrap();
1343
1344        // Upgrade to v2.0.0
1345        let v2_dir = make_program_dir(&temp_dir.path().join("v2"), "up-test", "2.0.0");
1346        let result = manager.upgrade(&v2_dir).await.unwrap();
1347        assert_eq!(result.meta.version, "2.0.0");
1348    }
1349
1350    #[tokio::test]
1351    async fn test_upgrade_preserves_enabled_state() {
1352        let temp_dir = tempfile::tempdir().unwrap();
1353        let programs_dir = temp_dir.path().join("programs");
1354
1355        let manager = ProgramManager::new(programs_dir);
1356        manager.init().await.unwrap();
1357
1358        // Install v1.0.0 and disable it
1359        let v1_dir = make_program_dir(temp_dir.path(), "up-test", "1.0.0");
1360        manager.install(&v1_dir).await.unwrap();
1361        manager.set_enabled("up-test", false).await.unwrap();
1362
1363        // Upgrade to v2.0.0 — should stay disabled
1364        let v2_dir = make_program_dir(&temp_dir.path().join("v2"), "up-test", "2.0.0");
1365        let result = manager.upgrade(&v2_dir).await.unwrap();
1366        assert_eq!(result.meta.version, "2.0.0");
1367        assert!(
1368            !result.enabled,
1369            "disabled state should be preserved across upgrade"
1370        );
1371    }
1372
1373    #[tokio::test]
1374    async fn test_upgrade_installs_if_not_present() {
1375        let temp_dir = tempfile::tempdir().unwrap();
1376        let programs_dir = temp_dir.path().join("programs");
1377
1378        let manager = ProgramManager::new(programs_dir);
1379        manager.init().await.unwrap();
1380
1381        // Upgrade without prior install — should just install
1382        let v1_dir = make_program_dir(temp_dir.path(), "fresh-test", "1.0.0");
1383        let result = manager.upgrade(&v1_dir).await.unwrap();
1384        assert_eq!(result.meta.name, "fresh-test");
1385        assert_eq!(result.meta.version, "1.0.0");
1386        assert!(result.enabled);
1387    }
1388}