1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4pub const MANIFEST_FILENAME: &str = "install.json";
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub enum InstallMode {
9 System,
10 Portable,
11}
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub enum InstallScope {
16 User,
17 Machine,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct InstallManifest {
23 pub manifest_version: u8,
24 pub mode: InstallMode,
25 pub scope: InstallScope,
26 #[serde(default)]
27 pub file_association: bool,
28}
29
30impl InstallManifest {
31 pub fn new(mode: InstallMode, scope: InstallScope) -> Self {
32 Self {
33 manifest_version: 1,
34 mode,
35 scope,
36 file_association: false,
37 }
38 }
39
40 pub fn with_file_association(mut self, file_association: bool) -> Self {
41 self.file_association = file_association;
42 self
43 }
44}
45
46pub fn write_manifest(root: &Path, manifest: &InstallManifest) -> Result<(), String> {
47 let json = serde_json::to_string_pretty(manifest).map_err(|e| format!("manifest json: {e}"))?;
48 std::fs::write(root.join(MANIFEST_FILENAME), json).map_err(|e| format!("write manifest: {e}"))
49}
50
51pub fn manifest_for_executable(exe: &Path) -> Result<Option<(PathBuf, InstallManifest)>, String> {
52 let Some(root) = install_root_from_executable(exe) else {
53 return Ok(None);
54 };
55 let json = std::fs::read_to_string(root.join(MANIFEST_FILENAME))
56 .map_err(|e| format!("read manifest: {e}"))?;
57 let manifest = serde_json::from_str(&json).map_err(|e| format!("parse manifest: {e}"))?;
58 Ok(Some((root, manifest)))
59}
60
61pub fn install_root_from_executable(exe: &Path) -> Option<PathBuf> {
62 let dir = exe.parent()?;
63 if dir.join(MANIFEST_FILENAME).is_file() {
64 return Some(dir.to_path_buf());
65 }
66 let parent = dir.parent()?;
67 let leaf = dir.file_name()?.to_string_lossy();
68 if matches_ci(&leaf, "app") || matches_ci(&leaf, "bin") {
69 let manifest = parent.join(MANIFEST_FILENAME);
70 if manifest.is_file() {
71 return Some(parent.to_path_buf());
72 }
73 }
74 None
75}
76
77fn matches_ci(left: &str, right: &str) -> bool {
78 left.eq_ignore_ascii_case(right)
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn detects_split_app_layout() {
87 let root = unique_temp_dir("split-app");
88 std::fs::create_dir_all(root.join("app")).unwrap();
89 std::fs::write(root.join(MANIFEST_FILENAME), "{}").unwrap();
90
91 assert_eq!(
92 install_root_from_executable(&root.join("app").join(binary_name("k580"))),
93 Some(root.clone())
94 );
95
96 let _ = std::fs::remove_dir_all(root);
97 }
98
99 #[test]
100 fn detects_split_bin_layout() {
101 let root = unique_temp_dir("split-bin");
102 std::fs::create_dir_all(root.join("bin")).unwrap();
103 std::fs::write(root.join(MANIFEST_FILENAME), "{}").unwrap();
104
105 assert_eq!(
106 install_root_from_executable(&root.join("bin").join(binary_name("kr"))),
107 Some(root.clone())
108 );
109
110 let _ = std::fs::remove_dir_all(root);
111 }
112
113 fn unique_temp_dir(name: &str) -> PathBuf {
114 let nanos = std::time::SystemTime::now()
115 .duration_since(std::time::UNIX_EPOCH)
116 .unwrap()
117 .as_nanos();
118 std::env::temp_dir().join(format!("k580-install-mode-{nanos}-{name}"))
119 }
120
121 #[cfg(windows)]
122 fn binary_name(name: &str) -> String {
123 format!("{name}.exe")
124 }
125
126 #[cfg(not(windows))]
127 fn binary_name(name: &str) -> String {
128 name.to_owned()
129 }
130}