tool_versions/
tool_versions.rs1use 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}