Skip to main content

evoke/
local.rs

1use std::path::Path;
2use std::path::PathBuf;
3use std::process::Command;
4
5use owo_colors::OwoColorize;
6use rootcause::report;
7
8/// List of binaries that should be copied after building.
9/// NOTE: if a new binary is added this list must be updated!
10const BINS: &[&str] = &[
11    "agg",
12    "catl",
13    "fkr",
14    "gbm",
15    "gch",
16    "gcu",
17    "ghl",
18    "idt",
19    "muxr",
20    "muxr-server",
21    "oe",
22    "rmr",
23    "frs",
24    "strgci",
25    "tec",
26    "try",
27    "vpg",
28    "yghfl",
29    "yhfp",
30];
31/// Binaries removed from the local deployment during the AI CLI migration.
32const REMOVED_BINS: &[&str] = &["ags", "aiya"];
33/// List of library files that need to be renamed after building, mapping (`source_name`, `target_name`).
34const LIBS: &[(&str, &str)] = &[("libnvrim.dylib", "nvrim.so")];
35/// Path segments for the default binaries install dir.
36const BINS_DEFAULT_PATH: &[&str] = &[".local", "bin"];
37/// Path segments for the Nvim libs install dir.
38const NVIM_LIBS_DEFAULT_PATH: &[&str] = &[".config", "nvim", "lua"];
39
40pub fn run(args: &mut Vec<String>) -> rootcause::Result<()> {
41    let is_debug = drop_element(args, "--debug");
42    let bins_path = args.first().cloned().map_or_else(
43        || ytil_sys::dir::build_home_path(BINS_DEFAULT_PATH),
44        |supplied_bins_path| Ok(PathBuf::from(supplied_bins_path)),
45    )?;
46    let cargo_target_path = args.get(1).cloned().map_or_else(
47        || {
48            std::env::var("CARGO_MANIFEST_DIR").map(|cargo_manifest_dir| {
49                let mut x = PathBuf::from(cargo_manifest_dir);
50                remove_last_n_dirs(&mut x, 2);
51                x.join("target")
52            })
53        },
54        |x| Ok(PathBuf::from(x)),
55    )?;
56    let nvim_libs_path = args.get(2).cloned().map_or_else(
57        || ytil_sys::dir::build_home_path(NVIM_LIBS_DEFAULT_PATH),
58        |supplied_nvim_libs_path| Ok(PathBuf::from(supplied_nvim_libs_path)),
59    )?;
60
61    let (cargo_target_location, build_profile) = if is_debug {
62        (cargo_target_path.join("debug"), None)
63    } else {
64        (cargo_target_path.join("release"), Some("--release"))
65    };
66
67    ytil_cmd::silent_cmd("cargo").args(["fmt"]).status()?.exit_ok()?;
68
69    // Skip clippy if debugging
70    if !is_debug {
71        ytil_cmd::silent_cmd("cargo")
72            .args(["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"])
73            .status()?
74            .exit_ok()?;
75    }
76
77    ytil_cmd::silent_cmd("cargo")
78        .args([Some("build"), build_profile].into_iter().flatten())
79        .status()?
80        .exit_ok()?;
81
82    for bin in REMOVED_BINS {
83        remove_legacy_bin(&bins_path.join(bin))?;
84    }
85
86    for bin in BINS {
87        cp(&cargo_target_location.join(bin), &bins_path.join(bin))?;
88    }
89
90    for (source_lib_name, target_lib_name) in LIBS {
91        cp(
92            &cargo_target_location.join(source_lib_name),
93            &nvim_libs_path.join(target_lib_name),
94        )?;
95    }
96
97    Command::new(bins_path.join("gbm")).arg("install").status()?.exit_ok()?;
98
99    Ok(())
100}
101
102/// Removes the last `n` directories from a [`PathBuf`].
103fn remove_last_n_dirs(path: &mut PathBuf, n: usize) {
104    for _ in 0..n {
105        if !path.pop() {
106            return;
107        }
108    }
109}
110
111/// Removes the first occurrence of an element from a vector.
112/// Returns `true` if found and removed, `false` otherwise.
113fn drop_element<T, U: ?Sized>(vec: &mut Vec<T>, target: &U) -> bool
114where
115    T: PartialEq<U>,
116{
117    let Some(idx) = vec.iter().position(|x| x == target) else {
118        return false;
119    };
120    vec.swap_remove(idx);
121    true
122}
123
124fn remove_legacy_bin(path: &Path) -> rootcause::Result<()> {
125    let metadata = match std::fs::symlink_metadata(path) {
126        Ok(metadata) => metadata,
127        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
128        Err(error) => {
129            return Err(
130                report!("cannot inspect legacy binary").attach(format!("path={} error={error}", path.display()))
131            );
132        }
133    };
134    let file_type = metadata.file_type();
135    if !file_type.is_file() && !file_type.is_symlink() {
136        return Err(report!("refusing to remove non-file legacy binary").attach(format!("path={}", path.display())));
137    }
138
139    std::fs::remove_file(path).map_err(|error| {
140        report!("cannot remove legacy binary").attach(format!("path={} error={error}", path.display()))
141    })?;
142    println!("{} {}", "Removed".red().bold(), path.display());
143    Ok(())
144}
145
146/// Copies a built binary or library from `from` to `to` using
147/// [`ytil_sys::file::atomic_cp`] and prints an "Copied" status line.
148///
149/// # Errors
150/// - [`ytil_sys::file::atomic_cp`] fails to copy.
151/// - The final rename or write cannot be performed.
152fn cp(from: &Path, to: &Path) -> rootcause::Result<()> {
153    ytil_sys::file::atomic_cp(from, to)?;
154    println!("{} {} to {}", "Copied".green().bold(), from.display(), to.display());
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use std::path::PathBuf;
161
162    use rstest::rstest;
163    use test_that::prelude::*;
164
165    use crate::local::drop_element;
166    use crate::local::remove_last_n_dirs;
167    use crate::local::remove_legacy_bin;
168
169    #[test]
170    fn test_drop_element_returns_true_and_removes_the_element_from_the_vec() {
171        let mut input = vec![42, 7];
172        assert_that!(drop_element(&mut input, &7), eq(true));
173        assert_eq!(input, vec![42]);
174    }
175
176    #[test]
177    fn test_drop_element_returns_false_and_does_nothing_to_a_non_empty_vec() {
178        let mut input = vec![42, 7];
179        assert_that!(drop_element(&mut input, &3), eq(false));
180        assert_eq!(input, vec![42, 7]);
181    }
182
183    #[test]
184    fn test_drop_element_returns_false_and_does_nothing_to_an_empty_vec() {
185        let mut input: Vec<usize> = vec![];
186        assert_that!(drop_element(&mut input, &3), eq(false));
187        assert_that!(input, empty());
188    }
189
190    #[rstest]
191    #[case::no_dirs_removed(PathBuf::from("/home/user/docs"), 0, PathBuf::from("/home/user/docs"))]
192    #[case::remove_one_dir(PathBuf::from("/home/user/docs"), 1, PathBuf::from("/home/user"))]
193    #[case::remove_more_than_exist(PathBuf::from("/home/user"), 5, PathBuf::from("/"))]
194    #[case::root_path(PathBuf::from("/"), 1, PathBuf::from("/"))]
195    #[case::empty_path(PathBuf::new(), 1, PathBuf::new())]
196    fn test_remove_last_n_dirs_when_requested_count_varies_updates_path(
197        #[case] mut initial: PathBuf,
198        #[case] n: usize,
199        #[case] expected: PathBuf,
200    ) {
201        remove_last_n_dirs(&mut initial, n);
202        assert_that!(initial, eq(expected));
203    }
204
205    #[rstest]
206    #[case::missing(false)]
207    #[case::file(true)]
208    fn test_remove_legacy_bin_handles_missing_and_file_paths(#[case] create_file: bool) {
209        let dir = tempfile::tempdir().expect("tempdir should be created");
210        let path = dir.path().join("ags");
211        if create_file {
212            std::fs::write(&path, "legacy binary").expect("legacy binary should be created");
213        }
214
215        assert_that!(remove_legacy_bin(&path), ok(eq(())));
216        assert_that!(path.exists(), eq(false));
217    }
218
219    #[test]
220    fn test_remove_legacy_bin_refuses_directory() {
221        let dir = tempfile::tempdir().expect("tempdir should be created");
222        let path = dir.path().join("aiya");
223        std::fs::create_dir(&path).expect("legacy directory should be created");
224
225        assert_that!(remove_legacy_bin(&path), err(anything()));
226        assert_that!(path.is_dir(), eq(true));
227    }
228}