Skip to main content

keel_cli/
flows_add.rs

1//! `keel flows add <entrypoint>` — one-command durability designation
2//! (dx-spec §1 Level 2: "Durability, still config-only").
3//!
4//! Appends the entrypoint to `[flows] entrypoints` in `keel.toml` through the
5//! policy-diff engine ([`crate::diff`]), so the edit is *surgical*: user
6//! comments and formatting outside the touched key survive byte-for-byte, the
7//! `[flows]` table is created when absent, and the change is also emitted as
8//! an applyable unified diff + structured hunks (dx-spec §5, diffs as the
9//! lingua franca). Re-running with the same entrypoint is an exact no-op.
10//!
11//! The one formatting caveat: the `entrypoints` array itself is re-rendered as
12//! a single-line array when appended to (the diff engine sets the whole
13//! value); everything outside that one line is untouched.
14//!
15//! Ergonomics: the bare forms `keel flows suggest` prints are accepted —
16//! `pipeline.ingest:main` infers `py:`, `jobs/nightly.ts#run` infers `ts:` —
17//! and explicit `py:` / `ts:` / `rs:` refs pass through. The final ref is
18//! validated against the frozen `entrypointRef` grammar
19//! (`contracts/policy.schema.json`: `^(py|ts|rs):[^\s]+$`) before anything is
20//! written.
21
22use std::path::Path;
23
24use serde::Serialize;
25use toml_edit::Value;
26
27use crate::diff::{ChangeHunk, PolicyOp, PolicyPath, propose};
28use crate::render::to_json;
29use crate::{EXIT_USAGE, Rendered, evidence};
30
31/// The machine twin of `keel flows add`.
32#[derive(Debug, Serialize)]
33struct AddReport {
34    /// True when the entrypoint was appended (false on an idempotent re-run).
35    added: bool,
36    /// True when the entrypoint was already designated.
37    already_designated: bool,
38    /// Structured `{path, before, after}` hunks (empty on a no-op).
39    changes: Vec<ChangeHunk>,
40    /// The normalized full ref that was (or already is) designated.
41    entrypoint: String,
42    /// Unified diff `a/keel.toml` → `b/keel.toml` (empty on a no-op).
43    patch: String,
44    /// True when `keel.toml` was written (false under `--diff` or a no-op).
45    written: bool,
46}
47
48/// `keel flows add <entrypoint> [--diff]` for `project`.
49pub fn run(project: &Path, entrypoint: &str, diff_only: bool) -> Rendered {
50    let full = match normalize_entrypoint(entrypoint) {
51        Ok(f) => f,
52        Err(e) => return usage_error(&e),
53    };
54    let toml_path = evidence::keel_toml(project);
55    let current = if toml_path.exists() {
56        match std::fs::read_to_string(&toml_path) {
57            Ok(t) => Some(t),
58            Err(e) => return usage_error(&format!("could not read {}: {e}", toml_path.display())),
59        }
60    } else {
61        None
62    };
63    let existing = match existing_entrypoints(current.as_deref()) {
64        Ok(list) => list,
65        Err(e) => return usage_error(&e),
66    };
67
68    if existing.iter().any(|e| e == &full) {
69        let human = format!(
70            "keel \u{25b8} {full} is already designated in [flows] entrypoints \u{2014} nothing to do."
71        );
72        return Rendered::ok(
73            human,
74            to_json(&AddReport {
75                added: false,
76                already_designated: true,
77                changes: Vec::new(),
78                entrypoint: full,
79                patch: String::new(),
80                written: false,
81            }),
82        );
83    }
84
85    let mut array = toml_edit::Array::new();
86    for e in &existing {
87        array.push(e.as_str());
88    }
89    array.push(full.as_str());
90    let ops = [PolicyOp::Set {
91        path: PolicyPath::new(["flows", "entrypoints"]),
92        value: Value::Array(array),
93    }];
94    let proposal = match propose(current.as_deref(), &ops) {
95        Ok(p) => p,
96        Err(e) => return usage_error(&e.to_string()),
97    };
98
99    let written = !diff_only;
100    if written && let Err(e) = std::fs::write(&toml_path, &proposal.new_text) {
101        return usage_error(&format!("could not write {}: {e}", toml_path.display()));
102    }
103
104    let human = if diff_only {
105        format!(
106            "keel \u{25b8} would designate {full} as a durable flow (keel.toml not written).\n\
107             \napply with `git apply` (or `patch -p1`):\n\n{}",
108            proposal.patch
109        )
110    } else {
111        format!(
112            "keel \u{25b8} designated {full} as a durable flow in keel.toml.\n  \
113             Run it with `keel run <script>`; kill it mid-run and the next `keel run` resumes.\n  \
114             Inspect with `keel flows` / `keel trace <flow>`."
115        )
116    };
117    Rendered::ok(
118        human,
119        to_json(&AddReport {
120            added: true,
121            already_designated: false,
122            changes: proposal.changes,
123            entrypoint: full,
124            patch: proposal.patch,
125            written,
126        }),
127    )
128}
129
130/// Normalize a user-supplied ref to a full `py:`/`ts:`/`rs:` entrypoint, or
131/// explain precisely why it cannot be one. Accepts the bare display forms
132/// `keel flows suggest` prints and infers their language namespace.
133fn normalize_entrypoint(raw: &str) -> Result<String, String> {
134    let raw = raw.trim();
135    if raw.is_empty() {
136        return Err(no_parse(raw, "it is empty"));
137    }
138    if raw.chars().any(char::is_whitespace) {
139        return Err(no_parse(raw, "it contains whitespace"));
140    }
141    for prefix in ["py:", "rs:", "ts:"] {
142        if let Some(rest) = raw.strip_prefix(prefix) {
143            if rest.is_empty() {
144                return Err(no_parse(raw, "nothing follows the language prefix"));
145            }
146            return Ok(raw.to_owned());
147        }
148    }
149    if is_python_shape(raw) {
150        return Ok(format!("py:{raw}"));
151    }
152    if is_ts_shape(raw) {
153        return Ok(format!("ts:{raw}"));
154    }
155    Err(no_parse(
156        raw,
157        "it is neither `module.path:function` (Python) nor `path/file.ts#function` (JS/TS)",
158    ))
159}
160
161/// The KEEL-E001 message for an unusable ref: what / why / what-next.
162fn no_parse(raw: &str, why: &str) -> String {
163    format!(
164        "{raw:?} is not a flow entrypoint ref: {why}. \
165         Use `py:module.path:function`, `ts:path/file.ts#function`, or a bare form \
166         from `keel flows suggest` (e.g. `pipeline.ingest:main`)."
167    )
168}
169
170/// `module(.module)*:function` where every part is a Python identifier.
171fn is_python_shape(s: &str) -> bool {
172    let Some((module, function)) = s.rsplit_once(':') else {
173        return false;
174    };
175    !module.is_empty()
176        && module.split('.').all(is_py_ident)
177        && is_py_ident(function)
178        && !s.contains('#')
179        && !s.contains('/')
180}
181
182fn is_py_ident(s: &str) -> bool {
183    let mut chars = s.chars();
184    chars
185        .next()
186        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
187        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
188}
189
190/// `path/to/file.<js-ext>#function` — the JS/TS entrypoint shape.
191fn is_ts_shape(s: &str) -> bool {
192    let Some((file, function)) = s.rsplit_once('#') else {
193        return false;
194    };
195    let has_js_ext = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx"]
196        .iter()
197        .any(|ext| file.ends_with(ext));
198    has_js_ext && !function.is_empty()
199}
200
201/// The `[flows] entrypoints` strings already in the policy. `None` current
202/// text means no file (empty list). A present-but-malformed `flows` table is a
203/// hard error — silently overwriting a user's non-array value would destroy
204/// their content.
205///
206/// Shared with `keel flows suggest`, which uses it (leniently — see
207/// [`crate::flows_suggest`]) to mark candidates already designated as flows.
208pub(crate) fn existing_entrypoints(current: Option<&str>) -> Result<Vec<String>, String> {
209    let Some(text) = current else {
210        return Ok(Vec::new());
211    };
212    let value: toml::Value = text
213        .parse()
214        .map_err(|e: toml::de::Error| format!("keel.toml is not valid TOML: {e}"))?;
215    let Some(flows) = value.get("flows") else {
216        return Ok(Vec::new());
217    };
218    let Some(flows) = flows.as_table() else {
219        return Err(
220            "keel.toml's `flows` key is not a table; fix it before adding entrypoints \
221                    (expected `[flows]` with `entrypoints = [\"py:…\"]`)."
222                .to_owned(),
223        );
224    };
225    let Some(entrypoints) = flows.get("entrypoints") else {
226        return Ok(Vec::new());
227    };
228    let Some(list) = entrypoints.as_array() else {
229        return Err(
230            "keel.toml's `flows.entrypoints` is not an array; fix it before adding entrypoints \
231             (expected `entrypoints = [\"py:…\"]`)."
232                .to_owned(),
233        );
234    };
235    list.iter()
236        .map(|v| {
237            v.as_str().map(str::to_owned).ok_or_else(|| {
238                "keel.toml's `flows.entrypoints` contains a non-string entry; fix it before \
239                 adding entrypoints."
240                    .to_owned()
241            })
242        })
243        .collect()
244}
245
246/// A policy/usage failure (KEEL-E001, exit 2, stderr) — mirrors `keel init`.
247fn usage_error(message: &str) -> Rendered {
248    #[derive(Serialize)]
249    struct ErrReport<'a> {
250        code: &'static str,
251        error: &'a str,
252    }
253    Rendered {
254        human: format!("keel \u{25b8} KEEL-E001: {message}"),
255        json: to_json(&ErrReport {
256            code: "KEEL-E001",
257            error: message,
258        }),
259        exit: EXIT_USAGE,
260        to_stderr: true,
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn project() -> tempfile::TempDir {
269        tempfile::TempDir::new().unwrap()
270    }
271
272    fn read_toml(dir: &tempfile::TempDir) -> String {
273        std::fs::read_to_string(dir.path().join("keel.toml")).unwrap()
274    }
275
276    // ---- normalization ----
277
278    #[test]
279    fn bare_python_shape_infers_py() {
280        assert_eq!(
281            normalize_entrypoint("pipeline.ingest:main").unwrap(),
282            "py:pipeline.ingest:main"
283        );
284        assert_eq!(normalize_entrypoint("app:run").unwrap(), "py:app:run");
285    }
286
287    #[test]
288    fn bare_ts_shape_infers_ts() {
289        assert_eq!(
290            normalize_entrypoint("jobs/nightly.ts#run").unwrap(),
291            "ts:jobs/nightly.ts#run"
292        );
293        assert_eq!(
294            normalize_entrypoint("app.mjs#fetchData").unwrap(),
295            "ts:app.mjs#fetchData"
296        );
297    }
298
299    #[test]
300    fn explicit_prefixes_pass_through() {
301        for full in ["py:pipeline.ingest:main", "ts:jobs/n.ts#run", "rs:crate::x"] {
302            assert_eq!(normalize_entrypoint(full).unwrap(), full);
303        }
304    }
305
306    #[test]
307    fn garbage_refs_are_rejected_with_guidance() {
308        for bad in ["", "  ", "just-a-name", "py:", "has space:fn", "file.txt#x"] {
309            let err = normalize_entrypoint(bad).unwrap_err();
310            assert!(err.contains("keel flows suggest"), "{err}");
311        }
312    }
313
314    // ---- the write path ----
315
316    #[test]
317    fn creates_keel_toml_and_flows_table_when_absent() {
318        let dir = project();
319        let r = run(dir.path(), "pipeline.ingest:main", false);
320        assert_eq!(r.exit, crate::EXIT_OK);
321        assert_eq!(r.json["added"], true);
322        assert_eq!(r.json["written"], true);
323        assert_eq!(r.json["entrypoint"], "py:pipeline.ingest:main");
324        let text = read_toml(&dir);
325        assert_eq!(
326            text,
327            "[flows]\nentrypoints = [\"py:pipeline.ingest:main\"]\n"
328        );
329        // The creation patch is /dev/null-headed and applyable.
330        let patch = r.json["patch"].as_str().unwrap();
331        assert!(
332            patch.starts_with("--- /dev/null\n+++ b/keel.toml\n"),
333            "{patch}"
334        );
335        assert_eq!(crate::diff::apply_unified("", patch).unwrap(), text);
336    }
337
338    #[test]
339    fn appends_to_existing_policy_preserving_user_bytes() {
340        let dir = project();
341        let existing = "\
342# my tuning \u{2014} keep me
343
344[target.\"api.example.com\"]        # seen in: app.py:4
345timeout = \"9s\"                      # tuned by us
346";
347        std::fs::write(dir.path().join("keel.toml"), existing).unwrap();
348        let r = run(dir.path(), "py:pipeline.ingest:main", false);
349        assert_eq!(r.exit, crate::EXIT_OK);
350        let text = read_toml(&dir);
351        assert!(
352            text.starts_with(existing),
353            "untouched bytes survive: {text}"
354        );
355        assert!(text.contains("[flows]\nentrypoints = [\"py:pipeline.ingest:main\"]\n"));
356        let patch = r.json["patch"].as_str().unwrap();
357        assert_eq!(crate::diff::apply_unified(existing, patch).unwrap(), text);
358        // Structured hunk names the flows table.
359        assert_eq!(r.json["changes"][0]["path"], "flows.entrypoints");
360    }
361
362    #[test]
363    fn appends_to_an_existing_entrypoints_array_in_order() {
364        let dir = project();
365        std::fs::write(
366            dir.path().join("keel.toml"),
367            "[flows]\nentrypoints = [\"py:a.b:c\"]\n",
368        )
369        .unwrap();
370        let r = run(dir.path(), "jobs/nightly.ts#run", false);
371        assert_eq!(r.exit, crate::EXIT_OK);
372        assert_eq!(
373            read_toml(&dir),
374            "[flows]\nentrypoints = [\"py:a.b:c\", \"ts:jobs/nightly.ts#run\"]\n"
375        );
376    }
377
378    #[test]
379    fn rerun_is_an_exact_noop() {
380        let dir = project();
381        let r1 = run(dir.path(), "pipeline.ingest:main", false);
382        assert_eq!(r1.json["added"], true);
383        let before = read_toml(&dir);
384        // Same ref, bare or prefixed: both are already designated.
385        for spelling in ["pipeline.ingest:main", "py:pipeline.ingest:main"] {
386            let r = run(dir.path(), spelling, false);
387            assert_eq!(r.exit, crate::EXIT_OK);
388            assert_eq!(r.json["added"], false);
389            assert_eq!(r.json["already_designated"], true);
390            assert_eq!(r.json["written"], false);
391            assert_eq!(r.json["patch"], "");
392        }
393        assert_eq!(read_toml(&dir), before, "file byte-identical after re-runs");
394    }
395
396    #[test]
397    fn diff_flag_previews_without_writing() {
398        let dir = project();
399        let r = run(dir.path(), "pipeline.ingest:main", true);
400        assert_eq!(r.exit, crate::EXIT_OK);
401        assert_eq!(r.json["written"], false);
402        assert!(
403            !dir.path().join("keel.toml").exists(),
404            "--diff never writes"
405        );
406        assert!(r.human.contains("apply with `git apply`"));
407        assert!(!r.json["patch"].as_str().unwrap().is_empty());
408    }
409
410    // ---- error paths ----
411
412    #[test]
413    fn invalid_toml_is_a_usage_error() {
414        let dir = project();
415        std::fs::write(dir.path().join("keel.toml"), "not [valid\n").unwrap();
416        let r = run(dir.path(), "pipeline.ingest:main", false);
417        assert_eq!(r.exit, EXIT_USAGE);
418        assert!(r.to_stderr);
419        assert!(r.human.contains("KEEL-E001"));
420        assert!(r.human.contains("not valid TOML"));
421    }
422
423    #[test]
424    fn non_array_entrypoints_is_refused_not_overwritten() {
425        let dir = project();
426        let original = "[flows]\nentrypoints = \"py:a:b\"\n";
427        std::fs::write(dir.path().join("keel.toml"), original).unwrap();
428        let r = run(dir.path(), "pipeline.ingest:main", false);
429        assert_eq!(r.exit, EXIT_USAGE);
430        assert!(r.human.contains("not an array"));
431        assert_eq!(read_toml(&dir), original, "nothing was destroyed");
432    }
433
434    #[test]
435    fn invalid_ref_is_a_usage_error_before_any_io() {
436        let dir = project();
437        let r = run(dir.path(), "not a ref", false);
438        assert_eq!(r.exit, EXIT_USAGE);
439        assert_eq!(r.json["code"], "KEEL-E001");
440        assert!(!dir.path().join("keel.toml").exists());
441    }
442}