Skip to main content

tool_versions/
tool_versions.rs

1// Copyright (c) 2025-2026 Michael S. Klishin and Contributors
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use std::collections::HashSet;
10use std::fmt;
11use std::fs;
12use std::io;
13use std::path::Path;
14
15use crate::Result;
16use crate::errors::Error;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct ToolEntry {
21    pub name: String,
22    pub versions: Vec<String>,
23}
24
25impl ToolEntry {
26    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
27        Self {
28            name: name.into(),
29            versions: vec![version.into()],
30        }
31    }
32
33    pub fn with_versions(name: impl Into<String>, versions: Vec<String>) -> Self {
34        Self {
35            name: name.into(),
36            versions,
37        }
38    }
39
40    pub fn primary_version(&self) -> Option<&str> {
41        self.versions.first().map(|s| s.as_str())
42    }
43}
44
45impl fmt::Display for ToolEntry {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "{} {}", self.name, self.versions.join(" "))
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52enum Line {
53    Tool(ToolEntry),
54    Comment(String),
55    Empty,
56}
57
58#[derive(Debug, Clone, Default)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60pub struct ToolVersions {
61    #[cfg_attr(feature = "serde", serde(skip))]
62    lines: Vec<Line>,
63    tools: Vec<ToolEntry>,
64}
65
66impl ToolVersions {
67    pub fn new() -> Self {
68        Self {
69            lines: Vec::new(),
70            tools: Vec::new(),
71        }
72    }
73
74    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
75        let content = fs::read_to_string(path)?;
76        Self::parse(&content)
77    }
78
79    pub fn load_or_default<P: AsRef<Path>>(path: P) -> Result<Self> {
80        match fs::read_to_string(path) {
81            Ok(content) => Self::parse(&content),
82            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::new()),
83            Err(e) => Err(e.into()),
84        }
85    }
86
87    pub fn parse(content: &str) -> Result<Self> {
88        let mut lines = Vec::new();
89        let mut tools = Vec::new();
90        let mut seen_tools = HashSet::new();
91
92        for (line_num, line) in content.lines().enumerate() {
93            let parsed = parse_line(line, line_num + 1)?;
94
95            if let Line::Tool(entry) = &parsed
96                && seen_tools.insert(entry.name.clone())
97            {
98                tools.push(entry.clone());
99            }
100
101            lines.push(parsed);
102        }
103
104        Ok(Self { lines, tools })
105    }
106
107    pub fn get(&self, tool_name: &str) -> Option<&ToolEntry> {
108        self.tools.iter().find(|e| e.name == tool_name)
109    }
110
111    pub fn get_version(&self, tool_name: &str) -> Option<&str> {
112        self.get(tool_name)?.primary_version()
113    }
114
115    pub fn set(&mut self, tool_name: &str, version: &str) {
116        self.set_versions(tool_name, vec![version.to_string()]);
117    }
118
119    pub fn set_versions(&mut self, tool_name: &str, versions: Vec<String>) {
120        let entry = ToolEntry::with_versions(tool_name, versions);
121
122        if let Some(idx) = self.tools.iter().position(|e| e.name == tool_name) {
123            self.tools[idx] = entry.clone();
124
125            for line in &mut self.lines {
126                if let Line::Tool(e) = line
127                    && e.name == tool_name
128                {
129                    *line = Line::Tool(entry);
130                    return;
131                }
132            }
133        } else {
134            self.tools.push(entry.clone());
135            self.lines.push(Line::Tool(entry));
136        }
137    }
138
139    pub fn remove(&mut self, tool_name: &str) -> bool {
140        let removed = self.tools.iter().position(|e| e.name == tool_name);
141
142        if let Some(idx) = removed {
143            self.tools.remove(idx);
144
145            self.lines.retain(|line| {
146                if let Line::Tool(e) = line {
147                    e.name != tool_name
148                } else {
149                    true
150                }
151            });
152
153            true
154        } else {
155            false
156        }
157    }
158
159    pub fn contains(&self, tool_name: &str) -> bool {
160        self.tools.iter().any(|e| e.name == tool_name)
161    }
162
163    pub fn tools(&self) -> impl Iterator<Item = &ToolEntry> {
164        self.tools.iter()
165    }
166
167    pub fn tool_names(&self) -> impl Iterator<Item = &str> {
168        self.tools.iter().map(|e| e.name.as_str())
169    }
170
171    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
172        fs::write(path, self.to_string())?;
173        Ok(())
174    }
175}
176
177impl fmt::Display for ToolVersions {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        if self.lines.is_empty() {
180            for tool in &self.tools {
181                writeln!(f, "{}", tool)?;
182            }
183        } else {
184            for line in &self.lines {
185                match line {
186                    Line::Tool(entry) => writeln!(f, "{}", entry)?,
187                    Line::Comment(text) => writeln!(f, "{}", text)?,
188                    Line::Empty => writeln!(f)?,
189                }
190            }
191        }
192        Ok(())
193    }
194}
195
196fn parse_line(line: &str, line_num: usize) -> Result<Line> {
197    let trimmed = line.trim();
198
199    if trimmed.is_empty() {
200        return Ok(Line::Empty);
201    }
202
203    if trimmed.starts_with('#') {
204        return Ok(Line::Comment(line.to_string()));
205    }
206
207    let content = if let Some(comment_pos) = trimmed.find('#') {
208        trimmed[..comment_pos].trim()
209    } else {
210        trimmed
211    };
212
213    let mut parts = content.split_whitespace();
214
215    let tool_name = parts.next().ok_or_else(|| Error::ParseError {
216        line: line_num,
217        message: "empty tool name".to_string(),
218    })?;
219
220    if !is_valid_tool_name(tool_name) {
221        return Err(Error::ParseError {
222            line: line_num,
223            message: format!("invalid tool name: {}", tool_name),
224        });
225    }
226
227    let versions: Vec<String> = parts.map(|s| s.to_string()).collect();
228
229    if versions.is_empty() {
230        return Err(Error::ParseError {
231            line: line_num,
232            message: format!("no version specified for tool: {}", tool_name),
233        });
234    }
235
236    Ok(Line::Tool(ToolEntry::with_versions(tool_name, versions)))
237}
238
239fn is_valid_tool_name(name: &str) -> bool {
240    !name.is_empty()
241        && name
242            .chars()
243            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
244}