1use crate::generate::{find_project_root, GenerateError};
16use std::fs;
17use std::path::{Path, PathBuf};
18use toml_edit::{value, Array, DocumentMut, Item, Value};
19
20pub struct AddArgs {
22 pub feature: String,
24 pub project_root: Option<PathBuf>,
26 pub dep_name: String,
29}
30
31#[derive(Debug, PartialEq, Eq)]
33pub enum AddOutcome {
34 Added,
37 AlreadyEnabled,
39}
40
41#[derive(Debug)]
42pub enum AddError {
43 UnknownFeature {
44 requested: String,
45 known: &'static [&'static str],
46 },
47 DependencyNotFound(String),
48 UnsupportedDependencyShape(String),
49 Toml {
52 path: PathBuf,
53 source: Box<toml_edit::TomlError>,
54 },
55 Io {
56 path: PathBuf,
57 source: std::io::Error,
58 },
59 ProjectRoot(GenerateError),
60}
61
62impl std::fmt::Display for AddError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::UnknownFeature { requested, known } => {
66 write!(f, "`{requested}` is not a known kick-rs feature. ")?;
67 write!(f, "Known features: {}", known.join(", "))
68 }
69 Self::DependencyNotFound(dep) => write!(
70 f,
71 "couldn't find a `{dep}` entry under [dependencies] in this project's Cargo.toml."
72 ),
73 Self::UnsupportedDependencyShape(dep) => write!(
74 f,
75 "`{dep}` dep is in a shape this command can't safely mutate (likely a non-inline table). \
76 Edit it by hand and add the feature to the `features` array."
77 ),
78 Self::Toml { path, source } => {
79 write!(f, "could not parse `{}`: {source}", path.display())
80 }
81 Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
82 Self::ProjectRoot(e) => write!(f, "{e}"),
83 }
84 }
85}
86
87impl std::error::Error for AddError {}
88
89pub const KNOWN_FEATURES: &[(&str, &str)] = &[
93 (
94 "macros",
95 "`#[service]`, `#[contributor]`, `#[get]`/`#[post]`/...",
96 ),
97 ("config", "Layered env / dotenv / TOML / JSON config loader"),
98 ("openapi", "OpenApiPlugin + paths!() — serve /openapi.json"),
99 (
100 "devtools",
101 "/__debug introspection endpoint (also needs .with_devtools())",
102 ),
103];
104
105fn known_feature_names() -> &'static [&'static str] {
106 &["macros", "config", "openapi", "devtools"]
110}
111
112pub fn add_feature(args: &AddArgs) -> Result<AddOutcome, AddError> {
114 if !known_feature_names().contains(&args.feature.as_str()) {
115 return Err(AddError::UnknownFeature {
116 requested: args.feature.clone(),
117 known: known_feature_names(),
118 });
119 }
120
121 let root = match &args.project_root {
122 Some(p) => p.clone(),
123 None => find_project_root(Path::new(".")).map_err(AddError::ProjectRoot)?,
124 };
125 let cargo_toml = root.join("Cargo.toml");
126 let contents = fs::read_to_string(&cargo_toml).map_err(|e| AddError::Io {
127 path: cargo_toml.clone(),
128 source: e,
129 })?;
130 let mut doc: DocumentMut = contents.parse().map_err(|e| AddError::Toml {
131 path: cargo_toml.clone(),
132 source: Box::new(e),
133 })?;
134
135 let outcome = mutate_features_array(&mut doc, &args.dep_name, &args.feature)?;
136
137 fs::write(&cargo_toml, doc.to_string()).map_err(|e| AddError::Io {
138 path: cargo_toml,
139 source: e,
140 })?;
141
142 Ok(outcome)
143}
144
145fn mutate_features_array(
148 doc: &mut DocumentMut,
149 dep_name: &str,
150 feature: &str,
151) -> Result<AddOutcome, AddError> {
152 let deps = doc
153 .get_mut("dependencies")
154 .and_then(|i| i.as_table_like_mut())
155 .ok_or_else(|| AddError::DependencyNotFound(dep_name.to_owned()))?;
156
157 let dep_item = deps
158 .get_mut(dep_name)
159 .ok_or_else(|| AddError::DependencyNotFound(dep_name.to_owned()))?;
160
161 upgrade_to_inline_table_if_needed(dep_item, dep_name)?;
162
163 let inline = dep_item
166 .as_inline_table_mut()
167 .ok_or_else(|| AddError::UnsupportedDependencyShape(dep_name.to_owned()))?;
168
169 let features_entry = inline.entry("features").or_insert_with(|| {
171 let arr = Array::new();
172 Value::Array(arr)
173 });
174 let arr = features_entry
175 .as_array_mut()
176 .ok_or_else(|| AddError::UnsupportedDependencyShape(dep_name.to_owned()))?;
177
178 let already = arr.iter().any(|v| v.as_str() == Some(feature));
180 if already {
181 return Ok(AddOutcome::AlreadyEnabled);
182 }
183
184 arr.push(feature);
185 Ok(AddOutcome::Added)
186}
187
188fn upgrade_to_inline_table_if_needed(item: &mut Item, dep_name: &str) -> Result<(), AddError> {
192 let Item::Value(v) = item else {
193 return Err(AddError::UnsupportedDependencyShape(dep_name.to_owned()));
197 };
198 match v {
199 Value::String(s) => {
200 let version = s.value().to_owned();
201 let mut table = toml_edit::InlineTable::new();
202 table.insert("version", value(version).into_value().unwrap());
203 *v = Value::InlineTable(table);
204 Ok(())
205 }
206 Value::InlineTable(_) => Ok(()),
207 _ => Err(AddError::UnsupportedDependencyShape(dep_name.to_owned())),
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn run_on_str(input: &str, dep: &str, feature: &str) -> Result<(String, AddOutcome), AddError> {
216 let mut doc: DocumentMut = input.parse().map_err(|e| AddError::Toml {
217 path: PathBuf::from("<test>"),
218 source: Box::new(e),
219 })?;
220 let outcome = mutate_features_array(&mut doc, dep, feature)?;
221 Ok((doc.to_string(), outcome))
222 }
223
224 #[test]
225 fn adds_feature_to_existing_features_array() {
226 let input = r#"
227[dependencies]
228kick-rs = { version = "0.1", features = ["macros"] }
229serde = "1"
230"#;
231 let (out, outcome) = run_on_str(input, "kick-rs", "openapi").unwrap();
232 assert_eq!(outcome, AddOutcome::Added);
233 assert!(
234 out.contains(r#"features = ["macros", "openapi"]"#),
235 "got:\n{out}"
236 );
237 assert!(out.contains(r#"serde = "1""#));
239 }
240
241 #[test]
242 fn idempotent_when_feature_already_present() {
243 let input = r#"
244[dependencies]
245kick-rs = { version = "0.1", features = ["openapi"] }
246"#;
247 let (out, outcome) = run_on_str(input, "kick-rs", "openapi").unwrap();
248 assert_eq!(outcome, AddOutcome::AlreadyEnabled);
249 assert_eq!(out.matches("openapi").count(), 1, "got:\n{out}");
251 }
252
253 #[test]
254 fn promotes_string_dep_to_inline_table() {
255 let input = r#"
256[dependencies]
257kick-rs = "0.1.0-alpha.1"
258"#;
259 let (out, outcome) = run_on_str(input, "kick-rs", "openapi").unwrap();
260 assert_eq!(outcome, AddOutcome::Added);
261 assert!(out.contains("version = \"0.1.0-alpha.1\""), "got:\n{out}");
263 assert!(out.contains(r#"features = ["openapi"]"#), "got:\n{out}");
264 }
265
266 #[test]
267 fn creates_features_array_when_absent_on_inline_table() {
268 let input = r#"
269[dependencies]
270kick-rs = { version = "0.1", path = "../kick-rs" }
271"#;
272 let (out, outcome) = run_on_str(input, "kick-rs", "macros").unwrap();
273 assert_eq!(outcome, AddOutcome::Added);
274 assert!(out.contains(r#"features = ["macros"]"#), "got:\n{out}");
275 assert!(out.contains(r#"path = "../kick-rs""#));
277 }
278
279 #[test]
280 fn errors_when_dep_not_found() {
281 let input = "[dependencies]\nserde = \"1\"\n";
282 let err = run_on_str(input, "kick-rs", "openapi").unwrap_err();
283 assert!(
284 matches!(err, AddError::DependencyNotFound(_)),
285 "got {err:?}"
286 );
287 }
288
289 #[test]
290 fn errors_on_non_inline_table_dep_shape() {
291 let input = r#"
295[dependencies.kick-rs]
296version = "0.1"
297features = ["macros"]
298"#;
299 let err = run_on_str(input, "kick-rs", "openapi").unwrap_err();
300 assert!(
301 matches!(err, AddError::UnsupportedDependencyShape(_)),
302 "got {err:?}"
303 );
304 }
305
306 fn make_skeleton_with_cargo(dir: &Path, cargo: &str) {
309 fs::create_dir_all(dir.join("src/modules")).unwrap();
310 fs::write(dir.join("src/modules/mod.rs"), "pub mod hello;\n").unwrap();
311 fs::write(dir.join("Cargo.toml"), cargo).unwrap();
312 }
313
314 #[test]
315 fn add_feature_writes_file() {
316 let tmp = tempfile::tempdir().unwrap();
317 let root = tmp.path().join("proj");
318 make_skeleton_with_cargo(
319 &root,
320 r#"[package]
321name = "x"
322version = "0.1.0"
323edition = "2021"
324
325[dependencies]
326kick-rs = { version = "0.1.0-alpha.1", features = ["macros"] }
327"#,
328 );
329
330 let outcome = add_feature(&AddArgs {
331 feature: "openapi".into(),
332 project_root: Some(root.clone()),
333 dep_name: "kick-rs".into(),
334 })
335 .unwrap();
336 assert_eq!(outcome, AddOutcome::Added);
337
338 let after = fs::read_to_string(root.join("Cargo.toml")).unwrap();
339 assert!(after.contains(r#"features = ["macros", "openapi"]"#));
340 }
341
342 #[test]
343 fn add_feature_rejects_unknown_name() {
344 let tmp = tempfile::tempdir().unwrap();
345 let root = tmp.path().join("proj");
346 make_skeleton_with_cargo(
347 &root,
348 "[package]\nname = \"x\"\n[dependencies]\nkick-rs = \"0.1\"\n",
349 );
350
351 let err = add_feature(&AddArgs {
352 feature: "tofu".into(),
353 project_root: Some(root.clone()),
354 dep_name: "kick-rs".into(),
355 })
356 .unwrap_err();
357 assert!(
358 matches!(err, AddError::UnknownFeature { .. }),
359 "got {err:?}"
360 );
361 let after = fs::read_to_string(root.join("Cargo.toml")).unwrap();
363 assert!(!after.contains("tofu"));
364 }
365}