Skip to main content

kick_rs_cli/
add.rs

1//! `cargo kick add <feature>` — toggle an opt-in `kick-rs` feature
2//! in the project's Cargo.toml.
3//!
4//! Why not just `cargo add kick-rs --features X`? Two reasons:
5//!
6//! 1. We validate the feature name against a known list, so a typo
7//!    fails fast with a list of what's available instead of producing
8//!    a working-but-useless `["typo"]` features array.
9//! 2. We can describe each feature in one line — `cargo kick add list`
10//!    is a quick reference without leaving the shell.
11//!
12//! The actual Cargo.toml mutation is done by `toml_edit` so the rest
13//! of the file (layout, comments, dep ordering) is left alone.
14
15use crate::generate::{find_project_root, GenerateError};
16use std::fs;
17use std::path::{Path, PathBuf};
18use toml_edit::{value, Array, DocumentMut, Item, Value};
19
20/// Decoded form of the `add` subcommand.
21pub struct AddArgs {
22    /// Feature name (e.g. `openapi`, `devtools`, `macros`, `config`).
23    pub feature: String,
24    /// Override the project root.
25    pub project_root: Option<PathBuf>,
26    /// Project's package name in Cargo.toml is `kick-rs`. Override if
27    /// the adopter renamed it (unlikely but supported).
28    pub dep_name: String,
29}
30
31/// Result of one `add` invocation.
32#[derive(Debug, PartialEq, Eq)]
33pub enum AddOutcome {
34    /// Feature added to the `features` array (and the `features` key
35    /// was created if it didn't exist).
36    Added,
37    /// Feature was already in the array.
38    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_edit::TomlError is ~128 bytes — box it to keep Result<_, AddError>
50    // small enough that clippy::result_large_err is satisfied.
51    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
89/// The features `cargo kick add` knows about, with a one-line
90/// description for `cargo kick add list`. Keep in sync with the
91/// `[features]` blocks in `crates/kick-rs/Cargo.toml`.
92pub 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    // `const` slice can't be derived from KNOWN_FEATURES without
107    // const-fn gymnastics, so we keep a parallel list. Kept tiny on
108    // purpose — adding a feature here is a deliberate decision.
109    &["macros", "config", "openapi", "devtools"]
110}
111
112/// Run the `add` flow.
113pub 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
145/// Format-preserving mutation: find `[dependencies] <dep_name>` and
146/// ensure `feature` is in its `features` array. Idempotent.
147fn 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    // After upgrade, `dep_item` is guaranteed to hold an inline table
164    // (or already was one).
165    let inline = dep_item
166        .as_inline_table_mut()
167        .ok_or_else(|| AddError::UnsupportedDependencyShape(dep_name.to_owned()))?;
168
169    // Read or create the `features` array.
170    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    // Idempotent — bail without mutating if already present.
179    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
188/// `kick-rs = "0.1.0-alpha.1"` (string form) doesn't have a `features`
189/// key to attach to. Promote it to an inline table form `{ version = ... }`
190/// in place so the caller can edit `features` uniformly.
191fn upgrade_to_inline_table_if_needed(item: &mut Item, dep_name: &str) -> Result<(), AddError> {
192    let Item::Value(v) = item else {
193        // Non-inline table (e.g. `[dependencies.kick-rs]` block) — we
194        // don't try to convert that. Adopters using that shape can
195        // edit features by hand, or convert to inline themselves.
196        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        // Other deps left alone.
238        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        // Single occurrence — no double-add.
250        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        // Now in inline-table form with both version and features.
262        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        // Existing keys preserved.
276        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        // `[dependencies.kick-rs]` block — we refuse to convert these,
292        // since collapsing them into inline form would be a noticeable
293        // formatting change.
294        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    // ─────────────────────── add_feature() — fs path ───────────────────────
307
308    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        // Cargo.toml left untouched.
362        let after = fs::read_to_string(root.join("Cargo.toml")).unwrap();
363        assert!(!after.contains("tofu"));
364    }
365}