Skip to main content

vs_app/
model.rs

1//! UI-agnostic view-model types and pure transforms.
2
3use vs_core::UseScope;
4use vs_plugin_api::PluginBackendKind;
5
6/// A tool (added plugin) shown in the sidebar.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ToolRow {
9    pub name: String,
10    pub current_version: Option<String>,
11}
12
13/// A version row in the Installed or Available section.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct VersionRow {
16    pub version: String,
17    pub installed: bool,
18    pub current: bool,
19}
20
21/// The scope a `use` action applies to.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ScopeChoice {
24    Project,
25    Global,
26    Session,
27}
28
29impl ScopeChoice {
30    pub fn all() -> [ScopeChoice; 3] {
31        [
32            ScopeChoice::Project,
33            ScopeChoice::Global,
34            ScopeChoice::Session,
35        ]
36    }
37
38    pub fn label(self) -> &'static str {
39        match self {
40            ScopeChoice::Project => "Project",
41            ScopeChoice::Global => "Global",
42            ScopeChoice::Session => "Session",
43        }
44    }
45
46    pub fn to_use_scope(self) -> UseScope {
47        match self {
48            ScopeChoice::Project => UseScope::Project,
49            ScopeChoice::Global => UseScope::Global,
50            ScopeChoice::Session => UseScope::Session,
51        }
52    }
53}
54
55/// Plugin backend chosen in the "Add from source" form.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum BackendChoice {
58    Lua,
59    Wasi,
60}
61
62impl BackendChoice {
63    pub fn to_kind(self) -> PluginBackendKind {
64        match self {
65            BackendChoice::Lua => PluginBackendKind::Lua,
66            BackendChoice::Wasi => PluginBackendKind::Wasi,
67        }
68    }
69}
70
71/// How the user wants to add a tool in the Add-tool dialog.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub enum AddSource {
74    Registry {
75        name: String,
76    },
77    Source {
78        source: String,
79        alias: Option<String>,
80        backend: BackendChoice,
81    },
82}
83
84/// Merge the list of added plugin names with their current-version statuses,
85/// producing sidebar rows sorted by name.
86pub fn merge_tool_rows(
87    names: Vec<String>,
88    statuses: Vec<(String, Option<String>)>,
89) -> Vec<ToolRow> {
90    let mut rows: Vec<ToolRow> = names
91        .into_iter()
92        .map(|name| {
93            let current_version = statuses
94                .iter()
95                .find(|(plugin, _)| *plugin == name)
96                .and_then(|(_, version)| version.clone());
97            ToolRow {
98                name,
99                current_version,
100            }
101        })
102        .collect();
103    rows.sort_by(|a, b| a.name.cmp(&b.name));
104    rows
105}
106
107/// Build the Installed-section rows, flagging the active version.
108pub fn installed_rows(installed: Vec<String>, current: Option<&str>) -> Vec<VersionRow> {
109    installed
110        .into_iter()
111        .map(|version| {
112            let is_current = current == Some(version.as_str());
113            VersionRow {
114                version,
115                installed: true,
116                current: is_current,
117            }
118        })
119        .collect()
120}
121
122/// Build the Available-section rows, marking versions already installed so the
123/// UI can disable their Install button.
124pub fn available_rows(found: Vec<String>, installed: &[String]) -> Vec<VersionRow> {
125    found
126        .into_iter()
127        .map(|version| {
128            let already = installed.iter().any(|v| v == &version);
129            VersionRow {
130                version,
131                installed: already,
132                current: false,
133            }
134        })
135        .collect()
136}
137
138/// Compute a `0.0..=100.0` percentage from downloaded/total bytes.
139///
140/// Returns `None` when the total is unknown or zero, signalling the UI to show
141/// an indeterminate spinner instead of a determinate bar.
142pub fn progress_percent(done: u64, total: Option<u64>) -> Option<f32> {
143    match total {
144        Some(total) if total > 0 => Some((done as f32 / total as f32 * 100.0).clamp(0.0, 100.0)),
145        _ => None,
146    }
147}
148
149/// Case-insensitive substring filter for the sidebar tool list.
150pub fn filter_tool_rows(rows: &[ToolRow], query: &str) -> Vec<ToolRow> {
151    if query.is_empty() {
152        return rows.to_vec();
153    }
154    let needle = query.to_lowercase();
155    rows.iter()
156        .filter(|row| row.name.to_lowercase().contains(&needle))
157        .cloned()
158        .collect()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn scope_choice_maps_to_use_scope_and_all_lists_three() {
167        assert_eq!(ScopeChoice::Project.to_use_scope(), UseScope::Project);
168        assert_eq!(ScopeChoice::Global.to_use_scope(), UseScope::Global);
169        assert_eq!(ScopeChoice::Session.to_use_scope(), UseScope::Session);
170        assert_eq!(ScopeChoice::all().len(), 3);
171        assert_eq!(ScopeChoice::Project.label(), "Project");
172    }
173
174    #[test]
175    fn backend_choice_maps_to_plugin_backend_kind() {
176        assert_eq!(BackendChoice::Lua.to_kind(), PluginBackendKind::Lua);
177        assert_eq!(BackendChoice::Wasi.to_kind(), PluginBackendKind::Wasi);
178    }
179
180    #[test]
181    fn merge_tool_rows_pairs_names_with_current_versions_sorted() {
182        let names = vec!["python".to_string(), "nodejs".to_string()];
183        let statuses = vec![
184            ("nodejs".to_string(), Some("20.11.1".to_string())),
185            ("python".to_string(), None),
186        ];
187        let rows = merge_tool_rows(names, statuses);
188        assert_eq!(
189            rows,
190            vec![
191                ToolRow {
192                    name: "nodejs".into(),
193                    current_version: Some("20.11.1".into())
194                },
195                ToolRow {
196                    name: "python".into(),
197                    current_version: None
198                },
199            ]
200        );
201    }
202
203    #[test]
204    fn installed_rows_flag_the_current_version() {
205        let rows = installed_rows(
206            vec!["20.11.1".to_string(), "18.19.0".to_string()],
207            Some("20.11.1"),
208        );
209        assert_eq!(
210            rows,
211            vec![
212                VersionRow {
213                    version: "20.11.1".into(),
214                    installed: true,
215                    current: true
216                },
217                VersionRow {
218                    version: "18.19.0".into(),
219                    installed: true,
220                    current: false
221                },
222            ]
223        );
224    }
225
226    #[test]
227    fn available_rows_mark_already_installed_versions() {
228        let rows = available_rows(
229            vec!["21.6.0".to_string(), "20.11.1".to_string()],
230            &["20.11.1".to_string()],
231        );
232        assert_eq!(
233            rows,
234            vec![
235                VersionRow {
236                    version: "21.6.0".into(),
237                    installed: false,
238                    current: false
239                },
240                VersionRow {
241                    version: "20.11.1".into(),
242                    installed: true,
243                    current: false
244                },
245            ]
246        );
247    }
248
249    #[test]
250    fn filter_tool_rows_is_case_insensitive_substring() {
251        let rows = vec![
252            ToolRow {
253                name: "nodejs".into(),
254                current_version: None,
255            },
256            ToolRow {
257                name: "python".into(),
258                current_version: None,
259            },
260        ];
261        let filtered = filter_tool_rows(&rows, "PY");
262        assert_eq!(filtered.len(), 1);
263        assert_eq!(filtered[0].name, "python");
264        // Empty query returns everything.
265        assert_eq!(filter_tool_rows(&rows, "").len(), 2);
266    }
267
268    #[test]
269    fn progress_percent_computes_clamped_ratio_or_none() {
270        assert_eq!(progress_percent(50, Some(200)), Some(25.0));
271        assert_eq!(progress_percent(200, Some(200)), Some(100.0));
272        // Over-report clamps to 100.
273        assert_eq!(progress_percent(300, Some(200)), Some(100.0));
274        // Unknown or zero total → None (caller shows a spinner).
275        assert_eq!(progress_percent(10, None), None);
276        assert_eq!(progress_percent(10, Some(0)), None);
277    }
278}