klasp_core/
plugin_disable.rs1use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19pub const KLASP_DISABLED_PLUGINS_FILE_ENV: &str = "KLASP_DISABLED_PLUGINS_FILE";
22
23#[derive(Debug, Default, Serialize, Deserialize)]
25struct DisableList {
26 #[serde(default)]
27 disabled: Vec<String>,
28}
29
30pub fn resolve_disable_list_path() -> PathBuf {
32 if let Ok(p) = std::env::var(KLASP_DISABLED_PLUGINS_FILE_ENV) {
33 return PathBuf::from(p);
34 }
35 let home = std::env::var("HOME")
36 .map(PathBuf::from)
37 .unwrap_or_else(|_| PathBuf::from("."));
38 home.join(".config")
39 .join("klasp")
40 .join("disabled-plugins.toml")
41}
42
43pub fn validate_plugin_name(name: &str) -> Result<(), String> {
47 if name.is_empty() {
48 return Err("plugin name is empty".to_string());
49 }
50 if !name
51 .chars()
52 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
53 {
54 return Err(format!(
55 "plugin name `{name}` contains invalid characters; allowed: ASCII letters, digits, `-`, `_`"
56 ));
57 }
58 Ok(())
59}
60
61pub fn load(path: Option<&Path>) -> HashSet<String> {
69 let resolved: PathBuf;
70 let p = match path {
71 Some(p) => p,
72 None => {
73 resolved = resolve_disable_list_path();
74 &resolved
75 }
76 };
77
78 let raw = match std::fs::read_to_string(p) {
79 Ok(s) => s,
80 Err(_) => return HashSet::new(),
81 };
82
83 match toml::from_str::<DisableList>(&raw) {
84 Ok(list) => list.disabled.into_iter().collect(),
85 Err(e) => {
86 eprintln!(
87 "warning: disable list at {} is malformed ({e}); ignoring (run `klasp plugins disable` to overwrite)",
88 p.display()
89 );
90 HashSet::new()
91 }
92 }
93}
94
95pub fn add(name: &str, path: Option<&Path>) -> Result<(), String> {
108 validate_plugin_name(name)?;
109
110 let resolved: PathBuf;
111 let p: &Path = match path {
112 Some(p) => p,
113 None => {
114 resolved = resolve_disable_list_path();
115 &resolved
116 }
117 };
118
119 if let Some(parent) = p.parent() {
120 std::fs::create_dir_all(parent)
121 .map_err(|e| format!("create dir {}: {e}", parent.display()))?;
122 }
123
124 let raw = std::fs::read_to_string(p).unwrap_or_default();
125 let mut list: DisableList = if raw.trim().is_empty() {
126 DisableList::default()
127 } else {
128 toml::from_str(&raw).map_err(|e| {
129 format!(
130 "disable list at {} is malformed: {e}; refusing to overwrite — fix or delete the file and retry",
131 p.display()
132 )
133 })?
134 };
135
136 if list.disabled.iter().any(|n| n == name) {
137 return Ok(());
138 }
139 list.disabled.push(name.to_string());
140
141 let serialized =
142 toml::to_string_pretty(&list).map_err(|e| format!("serialize disable list: {e}"))?;
143
144 let tmp = p.with_extension("toml.tmp");
145 std::fs::write(&tmp, &serialized)
146 .map_err(|e| format!("write temp file {}: {e}", tmp.display()))?;
147 std::fs::rename(&tmp, p)
148 .map_err(|e| format!("rename {} → {}: {e}", tmp.display(), p.display()))?;
149
150 Ok(())
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use tempfile::TempDir;
157
158 fn tmp_path(dir: &TempDir) -> PathBuf {
159 dir.path().join("disabled-plugins.toml")
160 }
161
162 #[test]
163 fn load_returns_empty_when_file_missing() {
164 let dir = TempDir::new().unwrap();
165 let path = tmp_path(&dir);
166 let set = load(Some(&path));
167 assert!(set.is_empty());
168 }
169
170 #[test]
171 fn add_creates_file_and_loads_back() {
172 let dir = TempDir::new().unwrap();
173 let path = tmp_path(&dir);
174 add("my-linter", Some(&path)).unwrap();
175 let set = load(Some(&path));
176 assert!(set.contains("my-linter"));
177 }
178
179 #[test]
180 fn add_is_idempotent() {
181 let dir = TempDir::new().unwrap();
182 let path = tmp_path(&dir);
183 add("my-linter", Some(&path)).unwrap();
184 add("my-linter", Some(&path)).unwrap();
185 let set = load(Some(&path));
186 assert_eq!(set.len(), 1);
187 }
188
189 #[test]
190 fn add_creates_parent_dir() {
191 let dir = TempDir::new().unwrap();
192 let path = dir
193 .path()
194 .join("nested")
195 .join("dir")
196 .join("disabled-plugins.toml");
197 add("my-linter", Some(&path)).unwrap();
198 assert!(path.exists());
199 }
200
201 #[test]
202 fn add_refuses_to_overwrite_malformed_file() {
203 let dir = TempDir::new().unwrap();
204 let path = tmp_path(&dir);
205 std::fs::write(&path, "this is not valid toml = = =").unwrap();
206 let result = add("my-linter", Some(&path));
207 assert!(result.is_err(), "expected Err on malformed file");
208 let msg = result.unwrap_err();
209 assert!(
210 msg.contains("malformed") && msg.contains("refusing to overwrite"),
211 "expected refusal message, got: {msg}"
212 );
213 let preserved = std::fs::read_to_string(&path).unwrap();
215 assert_eq!(preserved, "this is not valid toml = = =");
216 }
217
218 #[test]
219 fn load_returns_empty_on_malformed_file() {
220 let dir = TempDir::new().unwrap();
221 let path = tmp_path(&dir);
222 std::fs::write(&path, "this is not valid toml = = =").unwrap();
223 let set = load(Some(&path));
224 assert!(set.is_empty());
225 }
226
227 #[test]
228 fn validate_rejects_bad_names() {
229 assert!(validate_plugin_name("").is_err());
230 assert!(validate_plugin_name("../etc/passwd").is_err());
231 assert!(validate_plugin_name("name with space").is_err());
232 assert!(validate_plugin_name("name\nwith\nnewline").is_err());
233 assert!(validate_plugin_name("name;rm-rf").is_err());
234 }
235
236 #[test]
237 fn validate_accepts_valid_names() {
238 assert!(validate_plugin_name("my-linter").is_ok());
239 assert!(validate_plugin_name("my_linter_v2").is_ok());
240 assert!(validate_plugin_name("Linter123").is_ok());
241 }
242
243 #[test]
244 fn add_rejects_invalid_name() {
245 let dir = TempDir::new().unwrap();
246 let path = tmp_path(&dir);
247 let result = add("../etc/passwd", Some(&path));
248 assert!(result.is_err());
249 assert!(!path.exists(), "must not create file for invalid name");
250 }
251}