1use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde::Serialize;
10
11use crate::qapp_paths::{ensure_qapps_dir, qapps_dir, resolve_package_manifest_path};
12use crate::qapp_registry::QAPP_PACKAGE_MANIFEST;
13use crate::qapp_version::{is_version_newer, normalize_version_label};
14
15pub const DEFAULT_BUNDLED_QAPPS: &[&str] = &["Anatomy"];
17
18#[derive(Debug, Clone, Serialize)]
20pub struct QappVersionStatus {
21 pub qapp_name: String,
22 pub installed: bool,
23 pub installed_version: Option<String>,
24 pub offered_version: Option<String>,
25 pub update_available: bool,
26 pub offer_source: String,
28 pub message: String,
29}
30
31fn exe_dir() -> Option<PathBuf> {
32 std::env::current_exe()
33 .ok()
34 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
35}
36
37fn has_valid_manifest(dir: &Path) -> bool {
38 resolve_package_manifest_path(dir).is_some_and(|p| p.is_file())
39}
40
41pub fn read_qapp_version_from_dir(qapp_dir: &Path) -> Option<String> {
43 let manifest_path = resolve_package_manifest_path(qapp_dir)?;
44 let content = fs::read_to_string(manifest_path).ok()?;
45 let manifest: crate::qapp_registry::QappPackageManifest =
46 serde_json::from_str(&content).ok()?;
47 Some(normalize_version_label(&manifest.version))
48}
49
50pub fn resolve_bundled_qapp_source(qapp_name: &str) -> Option<PathBuf> {
52 if let Ok(extra) = std::env::var("QUALIA_BUNDLED_QAPPS_DIR") {
53 let candidate = PathBuf::from(extra).join(qapp_name);
54 if has_valid_manifest(&candidate) {
55 return Some(candidate);
56 }
57 }
58
59 if let Some(root) = exe_dir() {
60 for rel in [
61 format!("bundled/qapps/{qapp_name}"),
62 format!("qapps/{qapp_name}"),
63 format!("bundled/{qapp_name}"),
64 ] {
65 let candidate = root.join(&rel);
66 if has_valid_manifest(&candidate) {
67 return Some(candidate);
68 }
69 }
70 }
71
72 let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
73 for rel in [
74 format!("bundled/qapps/{qapp_name}"),
75 format!("app-development/{qapp_name}"),
76 ] {
77 let candidate = repo_root.join(&rel);
78 if has_valid_manifest(&candidate) {
79 return Some(candidate);
80 }
81 }
82
83 None
84}
85
86pub fn installed_qapp_version(storage_path: &Path, qapp_name: &str) -> Option<String> {
87 let dir = qapps_dir(storage_path).join(qapp_name);
88 if dir.join(QAPP_PACKAGE_MANIFEST).is_file() {
89 read_qapp_version_from_dir(&dir)
90 } else {
91 None
92 }
93}
94
95pub fn bundled_offered_version(qapp_name: &str) -> Option<String> {
96 resolve_bundled_qapp_source(qapp_name).and_then(|p| read_qapp_version_from_dir(&p))
97}
98
99fn build_version_status(
100 qapp_name: &str,
101 installed_version: Option<String>,
102 offered_version: Option<String>,
103 offer_source: &str,
104) -> QappVersionStatus {
105 let installed = installed_version.is_some();
106 let update_available = match (&installed_version, &offered_version) {
107 (Some(inst), Some(off)) => is_version_newer(off, inst),
108 (None, Some(_)) => true,
109 _ => false,
110 };
111
112 let message = match (&installed_version, &offered_version, update_available) {
113 (None, Some(off), true) => format!("Install qapp {qapp_name} v{off} from {offer_source}."),
114 (Some(inst), Some(off), true) => {
115 format!("Update available: v{inst} → v{off} ({offer_source}).")
116 }
117 (Some(inst), None, false) => format!("Installed v{inst}; no newer package found."),
118 (Some(inst), Some(off), false) if inst == off => {
119 format!("Up to date at v{inst}.")
120 }
121 (Some(inst), Some(off), false) => {
122 format!("Installed v{inst}; bundled/external offer v{off} is not newer.")
123 }
124 (None, None, false) => {
125 format!("{qapp_name} is not installed and no package source was found.")
126 }
127 _ => format!("{qapp_name} version status evaluated."),
128 };
129
130 QappVersionStatus {
131 qapp_name: qapp_name.to_string(),
132 installed,
133 installed_version,
134 offered_version,
135 update_available,
136 offer_source: offer_source.to_string(),
137 message,
138 }
139}
140
141pub fn check_bundled_qapp_update(qapp_name: &str, storage_path: &Path) -> QappVersionStatus {
143 let installed_version = installed_qapp_version(storage_path, qapp_name);
144 let offered_version = bundled_offered_version(qapp_name);
145 build_version_status(qapp_name, installed_version, offered_version, "bundled")
146}
147
148pub fn check_qapp_update_from_source(
150 qapp_name: &str,
151 storage_path: &Path,
152 source_dir: &Path,
153) -> Result<QappVersionStatus, String> {
154 if !has_valid_manifest(source_dir) {
155 return Err(format!("qapp.json not found in {}", source_dir.display()));
156 }
157 let offered_version = read_qapp_version_from_dir(source_dir)
158 .ok_or_else(|| "Could not read version from source qapp.json".to_string())?;
159 let installed_version = installed_qapp_version(storage_path, qapp_name);
160 Ok(build_version_status(
161 qapp_name,
162 installed_version,
163 Some(offered_version),
164 "external",
165 ))
166}
167
168pub fn list_bundled_qapp_updates(storage_path: &Path) -> Vec<QappVersionStatus> {
170 let mut names = Vec::new();
171 let qapps_root = qapps_dir(storage_path);
172 if let Ok(entries) = fs::read_dir(&qapps_root) {
173 for entry in entries.filter_map(Result::ok) {
174 if entry.path().is_dir() {
175 names.push(entry.file_name().to_string_lossy().to_string());
176 }
177 }
178 }
179 for name in DEFAULT_BUNDLED_QAPPS {
180 if !names.iter().any(|n| n == *name) {
181 names.push((*name).to_string());
182 }
183 }
184
185 let mut reports: Vec<QappVersionStatus> = names
186 .iter()
187 .map(|name| check_bundled_qapp_update(name, storage_path))
188 .collect();
189 reports.sort_by(|a, b| a.qapp_name.cmp(&b.qapp_name));
190 reports
191}
192
193pub fn seed_qapp_if_missing(
195 storage_path: impl AsRef<Path>,
196 qapp_name: &str,
197) -> Result<bool, String> {
198 let storage = storage_path.as_ref();
199 let dest = qapps_dir(storage).join(qapp_name);
200 if dest.join(QAPP_PACKAGE_MANIFEST).is_file() {
201 return Ok(false);
202 }
203
204 let source = resolve_bundled_qapp_source(qapp_name)
205 .ok_or_else(|| format!("Bundled qapp source not found for {qapp_name}"))?;
206
207 crate::qapp_install::install_package_atomic(
208 storage,
209 &source,
210 crate::qapp_install::InstallPolicy::Development,
211 None,
212 )
213 .map_err(|e| e.to_string())?;
214 Ok(true)
215}
216
217pub fn upgrade_qapp_from_source(
219 storage_path: &Path,
220 qapp_name: &str,
221 source_dir: &Path,
222) -> Result<String, String> {
223 if !has_valid_manifest(source_dir) {
224 return Err(format!("qapp.json not found in {}", source_dir.display()));
225 }
226
227 let manifest = crate::api::load_qapp_package_from_dir(source_dir)
228 .map_err(|e| format!("Invalid source manifest: {e}"))?;
229 if manifest.name != qapp_name {
230 return Err(format!(
231 "Source manifest name '{}' does not match expected '{}'",
232 manifest.name, qapp_name
233 ));
234 }
235
236 let previous = installed_qapp_version(storage_path, qapp_name);
237 let next = normalize_version_label(&manifest.version);
238 let entry = crate::qapp_install::install_package_atomic(
239 storage_path,
240 source_dir,
241 crate::qapp_install::InstallPolicy::Development,
242 None,
243 )
244 .map_err(|e| e.to_string())?;
245
246 let _ = crate::qapp_manifest::install_qapp_capabilities(&manifest);
247 let _ = entry;
248
249 Ok(match previous {
250 Some(old) => format!("Upgraded {qapp_name} v{old} → v{next}"),
251 None => format!("Installed {qapp_name} v{next}"),
252 })
253}
254
255pub fn apply_bundled_qapp_update(storage_path: &Path, qapp_name: &str) -> Result<String, String> {
257 let status = check_bundled_qapp_update(qapp_name, storage_path);
258 if !status.update_available {
259 return Err(status.message);
260 }
261 let source = resolve_bundled_qapp_source(qapp_name)
262 .ok_or_else(|| format!("Bundled qapp source not found for {qapp_name}"))?;
263 upgrade_qapp_from_source(storage_path, qapp_name, &source)
264}
265
266fn register_default_capabilities(_storage_path: &Path) {
267 for name in DEFAULT_BUNDLED_QAPPS {
268 if let Ok(manifest) = crate::api::load_installed_qapp_package(name) {
269 let _ = crate::qapp_manifest::install_qapp_capabilities(&manifest);
270 } else if let Some(src) = resolve_bundled_qapp_source(name) {
271 if let Ok(manifest) = crate::api::load_qapp_package_from_dir(&src) {
272 let _ = crate::qapp_manifest::install_qapp_capabilities(&manifest);
273 }
274 }
275 }
276}
277
278pub fn seed_bundled_qapps() -> Result<Vec<String>, String> {
280 let state = crate::state::APP_STATE
281 .get()
282 .ok_or("APP_STATE not initialized")?;
283 let storage = state
284 .config
285 .lock()
286 .map_err(|e| e.to_string())?
287 .storage_path
288 .clone();
289 let storage_path = PathBuf::from(&storage);
290 let _ = ensure_qapps_dir(&storage_path).map_err(|e| e.to_string())?;
291
292 let mut seeded = Vec::new();
293 for name in DEFAULT_BUNDLED_QAPPS {
294 match seed_qapp_if_missing(&storage_path, name) {
295 Ok(true) => seeded.push((*name).to_string()),
296 Ok(false) => {}
297 Err(e) => eprintln!("[bundled_qapps] skip {name}: {e}"),
298 }
299 }
300
301 register_default_capabilities(&storage_path);
302 let _ = crate::qapp_install::reconcile_registry_with_disk(&storage_path);
303
304 for offer in list_bundled_qapp_updates(&storage_path) {
305 if offer.update_available {
306 eprintln!(
307 "[bundled_qapps] update available for {}: {}",
308 offer.qapp_name, offer.message
309 );
310 }
311 }
312
313 Ok(seeded)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn tracked_anatomy_source_resolves_when_present() {
322 let tracked =
323 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../bundled/qapps/Anatomy/qapp.json");
324 if tracked.is_file() {
325 let src = resolve_bundled_qapp_source("Anatomy");
326 assert!(src.is_some(), "expected bundled Anatomy path");
327 }
328 }
329
330 #[test]
331 fn anatomy_manifest_version_reads_when_present() {
332 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
333 let anatomy = root.join("bundled/qapps/Anatomy");
334 if !anatomy.join("qapp.json").is_file() {
335 return;
336 }
337 let v = read_qapp_version_from_dir(&anatomy);
338 assert_eq!(v.as_deref(), Some("0.0.12"));
339 }
340}