1mod 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
41pub struct ProgramManager {
43 programs_dir: PathBuf,
45 installed: RwLock<HashMap<String, Program>>,
47}
48
49impl ProgramManager {
50 pub fn new(programs_dir: PathBuf) -> Self {
52 Self {
53 programs_dir,
54 installed: RwLock::new(HashMap::new()),
55 }
56 }
57
58 pub fn programs_dir(&self) -> &Path {
60 &self.programs_dir
61 }
62
63 pub async fn init(&self) -> Result<()> {
65 self.load_all().await
66 }
67
68 async fn load_all(&self) -> Result<()> {
71 if !self.programs_dir.exists() {
72 fs::create_dir_all(&self.programs_dir)?;
73 }
74
75 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 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 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 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 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 pub async fn list_programs(&self) -> Vec<Program> {
157 let installed = self.installed.read().await;
158 installed.values().cloned().collect()
159 }
160
161 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 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 pub async fn install(&self, source_path: &Path) -> Result<Program> {
175 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 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_dir_all(source_path, &dest_path)?;
192
193 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 let program = Program {
200 meta: source_meta,
201 path: dest_path,
202 skill_content,
203 enabled: true,
204 };
205
206 let mut installed = self.installed.write().await;
208 installed.insert(program.meta.name.clone(), program.clone());
209
210 Ok(program)
211 }
212
213 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 async fn install_from_local(&self, source_path: &Path) -> Result<Program> {
226 self.install(source_path).await
227 }
228
229 async fn install_from_git(&self, url: &str, branch: Option<&str>) -> Result<Program> {
231 let temp_dir = tempfile::tempdir().map_err(|e| anyhow::anyhow!("tempfile: {}", e))?;
233 let clone_path = temp_dir.path();
234
235 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 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 async fn install_from_tarball(&self, url: &str) -> Result<Program> {
283 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 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 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 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 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 if program.path.exists() {
358 fs::remove_dir_all(&program.path)?;
359 }
360
361 Ok(())
362 }
363
364 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 self.persist_state(&program.path, enabled)?;
376
377 tracing::info!(name, enabled, "Program enabled state persisted");
378 Ok(())
379 }
380
381 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 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 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 pub async fn upgrade(&self, source_path: &Path) -> Result<Program> {
427 let source_meta = ProgramMeta::load_from_dir(source_path)?;
428
429 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 let was_enabled = old.enabled;
463
464 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 if temp_path.exists() {
472 let _ = fs::remove_dir_all(&temp_path);
473 }
474
475 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 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 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 if target_path.exists() {
504 fs::remove_dir_all(&target_path)?;
505 }
506 fs::rename(&temp_path, &target_path)?;
507
508 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565enum VersionCmp {
566 Equal,
568 Newer,
570 Older,
572}
573
574fn 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 #[test]
607 fn test_program_meta_load_minimal() {
608 let temp_dir = tempfile::tempdir().unwrap();
609 let program_dir = temp_dir.path();
610
611 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 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 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 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 #[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 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 let programs = manager.list_programs().await;
891 assert_eq!(programs.len(), 1);
892 assert_eq!(programs[0].meta.name, "my-program");
893
894 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 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 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 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 assert!(check.missing_required.is_empty());
1113 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 #[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 #[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 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 manager.set_enabled("toggle-test", false).await.unwrap();
1252
1253 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 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 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 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 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 let v1_dir = make_program_dir(temp_dir.path(), "up-test", "1.0.0");
1324 manager.install(&v1_dir).await.unwrap();
1325
1326 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 let v1_dir = make_program_dir(temp_dir.path(), "up-test", "1.0.0");
1342 manager.install(&v1_dir).await.unwrap();
1343
1344 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 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 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 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}