aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs
blob: 8f6a234a9cc4fd8652e33d3658fc4c7cb6cb0365 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
use std::{fs, io};

use mingling::{
    Grouped, RenderResult, Routable, ShellContext, Suggest, SuggestItem,
    macros::{arg, chain, command, completion, metadata, pack, pack_err, renderer, routeify},
    metadata::Description,
    picker::{EntryPicker, PickerArg},
};

use crate::{
    Next, eprintln_cargo,
    pkg_mgr::{
        ErrorNoDataDirectory, ErrorPackageNameRequired, ErrorPackageSpecInvalid, ResPackagesDir,
    },
    println_cargo,
};

/// Positional argument: package spec (`foo`, `foo@0`, `foo@0.1`, `foo@0.1.2`)
pub static ARG_SPEC: PickerArg<String> = arg![String];

pack_err!(ErrorNoMatchingVersion = String);
pack!(StatePkgEnable = (String, String));

#[derive(Debug, Default, Grouped)]
pub struct ResultPkgEnable {
    pub name: String,
    pub version: String,
}

#[metadata(EntryPkgEnable)]
pub fn desc_pkg_enable() -> Description {
    "Enable the specified package".into()
}

#[command(node = "pkg-enable", routeify)]
pub fn package_enable(args: EntryPkgEnable, packages_dir: &ResPackagesDir) -> Next {
    let spec = args
        .pick_or_route(&ARG_SPEC, || ErrorPackageNameRequired::default().to_chain())
        .to_result()?;
    let packages_dir = &packages_dir.path;
    if packages_dir.as_os_str().is_empty() {
        return ErrorNoDataDirectory::default().to_chain();
    }
    if spec.contains('/') || spec.contains('\\') || spec.contains("..") {
        return ErrorPackageSpecInvalid::new(spec).to_chain();
    }

    let (name, version_part) = match spec.split_once('@') {
        Some((name, version)) => (name.to_string(), Some(version.to_string())),
        None => (spec.clone(), None),
    };

    // Collect every installed version matching the spec, pick the newest
    let mut candidates: Vec<(String, semver::Version)> = Vec::new();
    if let Ok(entries) = fs::read_dir(packages_dir) {
        for entry in entries.flatten() {
            if !entry.file_type().is_ok_and(|t| t.is_dir()) {
                continue;
            }
            let Some(dir_name) = entry.file_name().to_str().map(str::to_owned) else {
                continue;
            };
            let Some((dir_pkg, dir_version)) = dir_name.split_once('@') else {
                continue;
            };
            if dir_pkg != name {
                continue;
            }
            if let Some(part) = &version_part
                && !version_matches(dir_version, part)
            {
                continue;
            }
            if let Ok(version) = semver::Version::parse(dir_version) {
                candidates.push((dir_name, version));
            }
        }
    }

    let Some((_, version)) = candidates.into_iter().max_by(|a, b| a.1.cmp(&b.1)) else {
        return ErrorNoMatchingVersion::new(spec).to_chain();
    };

    StatePkgEnable::new((name, version.to_string())).to_chain()
}

#[chain(routeify)]
pub fn handle_state_pkg_enable(p: StatePkgEnable, packages_dir: &ResPackagesDir) -> Next {
    let (name, version) = p.inner;
    let packages_dir = &packages_dir.path;
    if packages_dir.as_os_str().is_empty() {
        return ErrorNoDataDirectory::default().to_chain();
    }

    let file = packages_dir.join(&name);
    fs::write(&file, &version).map_err(|e| {
        io::Error::new(e.kind(), format!("failed to write {}: {e}", file.display()))
    })?;

    ResultPkgEnable { name, version }.to_chain()
}

#[renderer]
pub fn render_result_pkg_enable(result: ResultPkgEnable) -> RenderResult {
    let mut r = RenderResult::new();
    println_cargo!(r, "Enabled: {}@{}", result.name, result.version);
    r
}

#[renderer]
pub fn render_error_no_matching_version(err: ErrorNoMatchingVersion) -> RenderResult {
    let mut r = RenderResult::new();
    eprintln_cargo!(r, "no matching version for: {}", err.info);
    r
}

#[completion(EntryPkgEnable)]
pub fn complete_pkg_enable(ctx: &ShellContext, packages_dir: &ResPackagesDir) -> Suggest {
    if ctx.previous_word != "pkg-enable" {
        return Suggest::FileCompletion;
    }
    let mut suggest = Suggest::new();
    if let Ok(entries) = fs::read_dir(&packages_dir.path) {
        for entry in entries.flatten() {
            if !entry.file_type().is_ok_and(|t| t.is_dir()) {
                continue;
            }
            if let Some(name) = entry.file_name().to_str() {
                suggest.insert(SuggestItem::Simple(name.to_string()));
            }
        }
    }
    suggest
}

/// Whether `version` falls under the partial spec, compared segment by segment.
/// Pre-release suffixes are ignored during matching, e.g. `0.2.1` matches `0.2.1-rc10`.
fn version_matches(version: &str, partial: &str) -> bool {
    let version = version.split('-').next().unwrap_or(version);
    let version_parts: Vec<&str> = version.split('.').collect();
    let partial_parts: Vec<&str> = partial.split('.').collect();
    if partial_parts.len() > version_parts.len() {
        return false;
    }
    partial_parts
        .iter()
        .zip(version_parts.iter())
        .all(|(p, v)| p == v)
}