1use crate::config::Config;
5use crate::database::{PackageDatabase, PackageInfo, PackageSource};
6use crate::manifest;
7use crate::output;
8use anyhow::{Context, Result};
9use std::io::{self, Write};
10use std::path::Path;
11use std::time::SystemTime;
12
13pub struct PackageInstaller {
14 config: Config,
15}
16
17fn normalize_github_url(repo: &str) -> String {
18 let s = repo.trim().trim_end_matches('/');
19 if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("git@") {
20 return s.to_string();
21 }
22 if s.contains('/') && !s.contains(' ') {
23 return format!("https://github.com/{}.git", s.trim_end_matches(".git"));
24 }
25 s.to_string()
26}
27
28impl PackageInstaller {
29 pub fn new(config: Config) -> Self {
30 Self { config }
31 }
32
33 pub fn install_from_github(&self, repo: &str, branch: Option<&str>) -> Result<String> {
34 let clone_url = normalize_github_url(repo);
35 let repo_url = repo.to_string();
36 let repo_name = repo
37 .split('/')
38 .next_back()
39 .context("Invalid GitHub repository format")?
40 .trim_end_matches(".git");
41
42 let temp_path = self
43 .config
44 .package_storage_path
45 .join(format!("{}_temp", repo_name));
46 if temp_path.exists() {
47 std::fs::remove_dir_all(&temp_path)?;
48 }
49
50 let mut repo_builder = git2::build::RepoBuilder::new();
51 if let Some(b) = branch {
52 repo_builder.branch(b);
53 }
54
55 let git_repo = repo_builder
56 .clone(&clone_url, &temp_path)
57 .context("Failed to clone repository")?;
58
59 let head = git_repo.head().context("Failed to get HEAD")?;
60 let commit = head.target().context("Failed to get commit OID")?;
61 let commit_str = commit.to_string();
62
63 let detected = manifest::detect_and_load(&temp_path)?;
64 output::step("Validating", "package manifest");
65 let summary = detected
66 .manifest
67 .validate_and_summarize()
68 .with_context(|| "Invalid robonix package manifest")?;
69 let package_name = summary.name.clone();
70 let version = summary.version.clone();
71 let target_path = self.config.package_storage_path.join(&package_name);
72
73 output::sub_step(&format!("Package: {} {}", package_name, version));
74
75 let db = PackageDatabase::load(&self.config.package_storage_path)?;
76 if let Some(existing) = db.get_package(&package_name) {
77 if !Self::prompt_overwrite(&existing.version, &version, &package_name)? {
78 std::fs::remove_dir_all(&temp_path)?;
79 output::info("Installation cancelled.");
80 std::process::exit(0);
81 }
82 if existing.path.exists() {
83 std::fs::remove_dir_all(&existing.path)?;
84 }
85 }
86
87 if target_path.exists() {
88 std::fs::remove_dir_all(&target_path)?;
89 }
90 std::fs::rename(&temp_path, &target_path)?;
91
92 let manifest_path = target_path.join(
93 detected
94 .path
95 .file_name()
96 .and_then(|n| n.to_str())
97 .unwrap_or(manifest::MANIFEST_FILE),
98 );
99 let package_info = Self::create_package_info(
100 &target_path,
101 &manifest_path,
102 &summary,
103 PackageSource::GitHub {
104 repo: repo_url,
105 branch: branch.map(String::from),
106 commit: commit_str,
107 },
108 )?;
109
110 let mut db = PackageDatabase::load(&self.config.package_storage_path)?;
111 db.add_package(package_info);
112 db.save(&self.config.package_storage_path)?;
113
114 Ok(package_name)
115 }
116
117 pub fn install_from_path(&self, source_path: &Path) -> Result<String> {
118 let source_path = source_path
119 .canonicalize()
120 .with_context(|| format!("Failed to canonicalize: {}", source_path.display()))?;
121
122 let detected = manifest::detect_and_load(&source_path)?;
123 output::step("Validating", "package manifest");
124 let summary = detected
125 .manifest
126 .validate_and_summarize()
127 .with_context(|| "Invalid robonix package manifest")?;
128 let package_name = summary.name.clone();
129 let version = summary.version.clone();
130
131 output::sub_step(&format!("Package: {} {}", package_name, version));
132
133 let db = PackageDatabase::load(&self.config.package_storage_path)?;
134 if let Some(existing) = db.get_package(&package_name) {
135 if !Self::prompt_overwrite(&existing.version, &version, &package_name)? {
136 output::info("Installation cancelled.");
137 std::process::exit(0);
138 }
139 if existing.path.exists() {
140 std::fs::remove_dir_all(&existing.path)?;
141 }
142 }
143
144 let target_path = self.config.package_storage_path.join(&package_name);
145 copy_dir_all(&source_path, &target_path).with_context(|| {
146 format!(
147 "Failed to copy from {} to {}",
148 source_path.display(),
149 target_path.display()
150 )
151 })?;
152
153 let manifest_path = target_path.join(
154 detected
155 .path
156 .file_name()
157 .and_then(|n| n.to_str())
158 .unwrap_or(manifest::MANIFEST_FILE),
159 );
160 let package_info = Self::create_package_info(
161 &target_path,
162 &manifest_path,
163 &summary,
164 PackageSource::Local { path: source_path },
165 )?;
166
167 let mut db = PackageDatabase::load(&self.config.package_storage_path)?;
168 db.add_package(package_info);
169 db.save(&self.config.package_storage_path)?;
170
171 Ok(package_name)
172 }
173
174 pub fn parse_manifest_name(manifest_path: &Path) -> Result<String> {
175 let manifest = manifest::load_from_path(manifest_path)?;
176 Ok(manifest.validate_and_summarize()?.name)
177 }
178
179 fn prompt_overwrite(old_version: &str, new_version: &str, package_name: &str) -> Result<bool> {
180 output::warning(&format!("Package '{}' is already installed", package_name));
181 output::sub_step(&format!("Old: {} New: {}", old_version, new_version));
182 print!("Overwrite? [y/N]: ");
183 io::stdout().flush()?;
184 let mut input = String::new();
185 io::stdin().read_line(&mut input)?;
186 let answer = input.trim().to_lowercase();
187 Ok(answer == "y" || answer == "yes")
188 }
189
190 pub fn create_package_info(
191 path: &Path,
192 manifest_path: &Path,
193 summary: &crate::manifest::PackageSummary,
194 source: PackageSource,
195 ) -> Result<PackageInfo> {
196 let installed_at = chrono::DateTime::<chrono::Utc>::from(SystemTime::now()).to_rfc3339();
197 Ok(PackageInfo {
198 name: summary.name.clone(),
199 version: summary.version.clone(),
200 path: path.to_path_buf(),
201 manifest_path: manifest_path.to_path_buf(),
202 capabilities: summary.capabilities.clone(),
203 depends: summary.depends.clone(),
204 installed_at,
205 source,
206 })
207 }
208}
209
210fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
211 std::fs::create_dir_all(dst)?;
212 for entry in std::fs::read_dir(src)? {
213 let entry = entry?;
214 let src_path = entry.path();
215 let dst_path = dst.join(entry.file_name());
216 if entry.file_type()?.is_dir() {
217 copy_dir_all(&src_path, &dst_path)?;
218 } else {
219 std::fs::copy(&src_path, &dst_path)?;
220 }
221 }
222 Ok(())
223}