1use 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#[derive(Debug, Serialize)]
33struct AddReport {
34 added: bool,
36 already_designated: bool,
38 changes: Vec<ChangeHunk>,
40 entrypoint: String,
42 patch: String,
44 written: bool,
46}
47
48pub 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
130fn 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
161fn 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
170fn 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
190fn 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
201pub(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
246fn 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 #[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 #[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 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 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 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 #[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}