1use std::path::{Path, PathBuf};
29
30use anyhow::{bail, Context, Result};
31use cap_fs_ext::DirExt as _;
32use cap_std::{ambient_authority, fs::Dir};
33use kranz_engine::error::EngineError;
34use kranz_engine::ticket::Ticket;
35
36const MAX_IMPORT_BYTES: u64 = 8 * 1024 * 1024;
37const MAX_SPEC_ENTRIES: usize = 1024;
38const MAX_SPEC_DEPTH: usize = 32;
39
40#[derive(Debug, PartialEq, Eq)]
42pub struct OpenSpecChange {
43 pub slug: String,
45 pub title: String,
47 pub goal: String,
49 pub context: String,
51}
52
53pub const ACCEPTANCE_PLACEHOLDER: &str = "\
59The requirements imported from this change are INTENT, not criteria: a SHALL \
60sentence cannot fail, so none of them were copied here. Replace this with \
61commands that can fail, each guarded by a passed count \
62(`grep -qE 'result: ok\\. [1-9][0-9]* passed'`) so a zero-match filter cannot \
63report success.";
64
65pub fn read_change(dir: &Path, slug_override: Option<&str>) -> Result<OpenSpecChange> {
71 read_change_pinned(open_change_root(None, dir)?, dir, slug_override)
72}
73
74fn open_change_root(repo: Option<&Path>, dir: &Path) -> Result<Dir> {
75 if let Some(repo) = repo {
76 let absolute_dir = std::path::absolute(dir)?;
77 let canonical_repo = repo.canonicalize()?;
78 if let Some(anchor) = absolute_dir.ancestors().find(|ancestor| {
83 ancestor
84 .canonicalize()
85 .is_ok_and(|path| path == canonical_repo)
86 }) {
87 let relative = absolute_dir.strip_prefix(anchor)?;
88 let mut root = Dir::open_ambient_dir(&canonical_repo, ambient_authority())?;
89 for component in relative.components() {
90 let std::path::Component::Normal(name) = component else {
91 bail!("import path must stay beneath its repository anchor");
92 };
93 root = root.open_dir_nofollow(name).with_context(|| {
94 format!(
95 "could not open change directory {} without following links",
96 dir.display()
97 )
98 })?;
99 }
100 return Ok(root);
101 }
102 }
103 let parent = dir
106 .parent()
107 .filter(|path| !path.as_os_str().is_empty())
108 .unwrap_or(Path::new("."));
109 let name = dir
110 .file_name()
111 .context("change directory must have a name")?;
112 Dir::open_ambient_dir(parent, ambient_authority())?
113 .open_dir_nofollow(name)
114 .with_context(|| {
115 format!(
116 "could not open change directory {} without following links",
117 dir.display()
118 )
119 })
120}
121
122fn read_change_pinned(
123 root: Dir,
124 dir: &Path,
125 slug_override: Option<&str>,
126) -> Result<OpenSpecChange> {
127 let mut remaining = MAX_IMPORT_BYTES;
128 let proposal_path = dir.join("proposal.md");
129 let proposal =
130 read_text(&root, Path::new("proposal.md"), &mut remaining).with_context(|| {
131 format!(
132 "no OpenSpec proposal at {}; an OpenSpec change directory holds proposal.md \
133 (plus optional design.md, specs/, tasks.md)",
134 proposal_path.display()
135 )
136 })?;
137
138 let dir_name = dir
139 .file_name()
140 .map(|name| name.to_string_lossy().into_owned())
141 .unwrap_or_default();
142 if dir_name.is_empty() {
143 bail!("could not read a change name from {}", dir.display());
144 }
145 let slug = slug_override
146 .map(str::to_string)
147 .unwrap_or_else(|| slugify(&dir_name));
148
149 let (heading, body) = split_leading_heading(&proposal);
150 let title = heading.unwrap_or_else(|| dir_name.clone());
151
152 let mut context = String::new();
153 context.push_str(&format!(
154 "Imported from the OpenSpec change `{}`. The proposal and requirements below are \
155 the authored intent; this ticket is what kranz executes, and the approved plan is \
156 what the validator judges. `tasks.md` was deliberately not imported: it is the \
157 authoring assistant's own decomposition, and the orchestrator re-plans this work \
158 rather than inheriting an ungraded checklist.\n",
159 dir.display()
160 ));
161
162 if let Some(design) = read_optional(&root, Path::new("design.md"), &mut remaining)? {
163 context.push_str(&format!("\n### Design notes (design.md)\n\n{design}\n"));
164 }
165
166 for (path, spec) in read_specs(&root, &dir.join("specs"), &mut remaining)? {
167 context.push_str(&format!(
168 "\n### Requirements as intent ({})\n\n{spec}\n",
169 path.display()
170 ));
171 }
172
173 Ok(OpenSpecChange {
174 slug,
175 title,
176 goal: body.trim().to_string(),
177 context: context.trim_end().to_string(),
178 })
179}
180
181pub fn import_change(repo: &Path, dir: &Path, slug_override: Option<&str>) -> Result<PathBuf> {
187 let change = read_change_pinned(open_change_root(Some(repo), dir)?, dir, slug_override)?;
188 let mut body =
189 Ticket::ticket_template(&change.title, Some(&change.goal), Some(&change.context));
190 body.push_str(&format!("\n{ACCEPTANCE_PLACEHOLDER}\n"));
191 Ok(Ticket::create_markdown(repo, &change.slug, &body)?)
192}
193
194fn read_text(dir: &Dir, name: &Path, remaining: &mut u64) -> kranz_engine::error::Result<String> {
195 let text = kranz_engine::paths::read_regular_file_under(dir, name, *remaining)?;
196 *remaining -= text.len() as u64;
197 Ok(text)
198}
199
200fn read_optional(dir: &Dir, name: &Path, remaining: &mut u64) -> Result<Option<String>> {
201 match read_text(dir, name, remaining) {
202 Ok(text) if text.trim().is_empty() => Ok(None),
203 Ok(text) => Ok(Some(text.trim().to_string())),
204 Err(EngineError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
205 Err(error) => Err(anyhow::Error::from(error))
206 .with_context(|| format!("could not read {}", name.display())),
207 }
208}
209
210fn read_specs(
213 root: &Dir,
214 specs_path: &Path,
215 remaining: &mut u64,
216) -> Result<Vec<(PathBuf, String)>> {
217 let mut found = Vec::new();
218 let specs = match root.open_dir_nofollow("specs") {
219 Ok(dir) => dir,
220 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(found),
221 Err(error) => return Err(error).context("could not open specs without following links"),
222 };
223 let mut remaining_entries = MAX_SPEC_ENTRIES;
224 collect_markdown(
225 &specs,
226 specs_path,
227 &mut found,
228 remaining,
229 &mut remaining_entries,
230 0,
231 )?;
232 found.sort_by(|(left, _), (right, _)| left.cmp(right));
233 Ok(found)
234}
235
236fn collect_markdown(
237 dir: &Dir,
238 display_path: &Path,
239 out: &mut Vec<(PathBuf, String)>,
240 remaining_bytes: &mut u64,
241 remaining_entries: &mut usize,
242 depth: usize,
243) -> Result<()> {
244 if depth > MAX_SPEC_DEPTH {
245 bail!("OpenSpec specs exceed the directory depth limit ({MAX_SPEC_DEPTH})");
246 }
247 for entry in dir.entries()? {
248 let entry = entry?;
249 if *remaining_entries == 0 {
250 bail!("OpenSpec specs exceed the entry limit ({MAX_SPEC_ENTRIES})");
251 }
252 *remaining_entries -= 1;
253 let name = PathBuf::from(entry.file_name());
254 let path = display_path.join(&name);
255 let kind = entry.file_type()?;
256 if kind.is_symlink() {
257 bail!("refusing linked OpenSpec input {}", path.display());
258 }
259 if kind.is_dir() {
260 let child = dir.open_dir_nofollow(&name).with_context(|| {
261 format!("could not open {} without following links", path.display())
262 })?;
263 collect_markdown(
264 &child,
265 &path,
266 out,
267 remaining_bytes,
268 remaining_entries,
269 depth + 1,
270 )?;
271 } else if name.extension().is_some_and(|ext| ext == "md") {
272 let text = read_text(dir, &name, remaining_bytes)?;
275 if !text.trim().is_empty() {
276 let text = text.trim().to_owned();
277 out.push((path, text));
278 }
279 }
280 }
281 Ok(())
282}
283
284fn split_leading_heading(markdown: &str) -> (Option<String>, String) {
286 let trimmed = markdown.trim_start();
287 let Some(rest) = trimmed.strip_prefix("# ") else {
288 return (None, markdown.to_string());
289 };
290 let (heading, body) = rest.split_once('\n').unwrap_or((rest, ""));
291 (Some(heading.trim().to_string()), body.to_string())
292}
293
294fn slugify(name: &str) -> String {
297 let slug: String = name
298 .to_ascii_lowercase()
299 .chars()
300 .map(|c| {
301 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
302 c
303 } else {
304 '-'
305 }
306 })
307 .collect();
308 slug.trim_matches('-').to_string()
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[cfg(unix)]
316 #[test]
317 fn imports_refuse_links_at_every_untrusted_component() {
318 use std::os::unix::fs::symlink;
319 for component in [
320 "root",
321 "proposal.md",
322 "design.md",
323 "specs",
324 "specs/theme.md",
325 "specs/nested",
326 ] {
327 let repo = tempfile::tempdir().unwrap();
328 let dir = write_change(repo.path());
329 let outside = tempfile::tempdir().unwrap();
330 let sentinel = outside.path().join("sentinel.md");
331 std::fs::write(&sentinel, "synthetic outside content").unwrap();
332 let selected = if component == "root" {
333 let linked = repo.path().join("linked-change");
334 symlink(&dir, &linked).unwrap();
335 linked
336 } else {
337 let path = dir.join(component);
338 if path.is_dir() {
339 std::fs::remove_dir_all(&path).unwrap();
340 } else if path.exists() {
341 std::fs::remove_file(&path).unwrap();
342 }
343 let target = if component == "specs" || component == "specs/nested" {
344 outside.path()
345 } else {
346 &sentinel
347 };
348 symlink(target, path).unwrap();
349 dir
350 };
351 assert!(
352 import_change(repo.path(), &selected, Some("example")).is_err(),
353 "{component}"
354 );
355 assert!(
356 !repo.path().join(".kranz/tickets/example.md").exists(),
357 "{component}"
358 );
359 assert_eq!(
360 std::fs::read_to_string(&sentinel).unwrap(),
361 "synthetic outside content"
362 );
363 }
364 }
365
366 #[cfg(unix)]
367 #[test]
368 fn repository_source_ancestors_cannot_redirect_imports() {
369 let repo = tempfile::tempdir().unwrap();
370 let external = tempfile::tempdir().unwrap();
371 let change = write_change(external.path());
372 std::os::unix::fs::symlink(
373 external.path().join("openspec"),
374 repo.path().join("openspec"),
375 )
376 .unwrap();
377 let indirect = repo.path().join("openspec/changes/dark mode");
378 assert!(import_change(repo.path(), &indirect, Some("indirect")).is_err());
379 assert!(!repo.path().join(".kranz/tickets/indirect.md").exists());
380 assert!(import_change(repo.path(), &change, Some("explicit")).is_ok());
382 }
383
384 #[cfg(unix)]
385 #[test]
386 fn checkout_alias_does_not_turn_repository_inputs_into_external_authority() {
387 let repo = tempfile::tempdir().unwrap();
388 let external = tempfile::tempdir().unwrap();
389 let aliases = tempfile::tempdir().unwrap();
390 write_change(external.path());
391 let alias = aliases.path().join("checkout");
392 std::os::unix::fs::symlink(repo.path(), &alias).unwrap();
393 write_change(repo.path());
394 let source = alias.join("openspec/changes/dark mode");
395 assert!(import_change(repo.path(), &source, Some("local")).is_ok());
396 std::fs::remove_dir_all(repo.path().join("openspec")).unwrap();
397 std::os::unix::fs::symlink(
398 external.path().join("openspec"),
399 repo.path().join("openspec"),
400 )
401 .unwrap();
402 assert!(import_change(repo.path(), &source, Some("indirect")).is_err());
403 assert!(!repo.path().join(".kranz/tickets/indirect.md").exists());
404 }
405
406 #[test]
407 fn imports_bound_aggregate_bytes_and_directory_depth() {
408 let repo = tempfile::tempdir().unwrap();
409 let dir = write_change(repo.path());
410 let file = std::fs::File::create(dir.join("design.md")).unwrap();
411 file.set_len(MAX_IMPORT_BYTES).unwrap();
412 assert!(
413 read_change(&dir, None).is_err(),
414 "proposal and design together exceed the budget"
415 );
416 std::fs::remove_file(dir.join("design.md")).unwrap();
417 let mut nested = dir.join("specs");
418 for _ in 0..=MAX_SPEC_DEPTH {
419 nested.push("nested");
420 }
421 std::fs::create_dir_all(nested).unwrap();
422 assert!(read_change(&dir, None)
423 .unwrap_err()
424 .to_string()
425 .contains("depth limit"));
426 }
427
428 #[test]
429 fn imports_bound_entry_count_even_for_empty_directories() {
430 let repo = tempfile::tempdir().unwrap();
431 let dir = write_change(repo.path());
432 for index in 0..MAX_SPEC_ENTRIES {
433 std::fs::create_dir(dir.join("specs").join(index.to_string())).unwrap();
434 }
435 assert!(read_change(&dir, None)
436 .unwrap_err()
437 .to_string()
438 .contains("entry limit"));
439 }
440
441 #[test]
442 fn nested_regular_specs_are_imported_in_path_order() {
443 let repo = tempfile::tempdir().unwrap();
444 let dir = write_change(repo.path());
445 std::fs::create_dir(dir.join("specs/aaa")).unwrap();
446 std::fs::write(dir.join("specs/aaa/first.md"), "First nested requirement").unwrap();
447 let change = read_change(&dir, None).unwrap();
448 assert!(
449 change.context.find("First nested requirement").unwrap()
450 < change.context.find("SHALL").unwrap()
451 );
452 }
453
454 fn write_change(root: &Path) -> PathBuf {
455 let dir = root.join("openspec").join("changes").join("dark mode");
456 std::fs::create_dir_all(dir.join("specs")).unwrap();
457 std::fs::write(
458 dir.join("proposal.md"),
459 "# Add a dark theme\n\nUsers on night shift ask for it weekly.\n",
460 )
461 .unwrap();
462 std::fs::write(
463 dir.join("design.md"),
464 "A CSS custom property per surface colour.\n",
465 )
466 .unwrap();
467 std::fs::write(
468 dir.join("specs").join("theme.md"),
469 "The app SHALL default to the system preference.\n",
470 )
471 .unwrap();
472 std::fs::write(
473 dir.join("tasks.md"),
474 "- [x] Add the toggle\n- [ ] Ship it\n",
475 )
476 .unwrap();
477 dir
478 }
479
480 #[test]
481 fn import_carries_proposal_and_specs_but_never_the_task_checklist() {
482 let repo = tempfile::tempdir().unwrap();
483 let dir = write_change(repo.path());
484
485 let path = import_change(repo.path(), &dir, None).unwrap();
486 let ticket = std::fs::read_to_string(&path).unwrap();
487
488 assert!(path.ends_with(".kranz/tickets/dark-mode.md"), "{path:?}");
489 assert!(ticket.contains("title: Add a dark theme"), "{ticket}");
490 assert!(ticket.contains("night shift"), "{ticket}");
491 assert!(ticket.contains("CSS custom property"), "{ticket}");
492 assert!(
493 ticket.contains("SHALL default to the system preference"),
494 "{ticket}"
495 );
496
497 assert!(!ticket.contains("Add the toggle"), "{ticket}");
500 assert!(!ticket.contains("Ship it"), "{ticket}");
501 }
502
503 #[test]
504 fn acceptance_hints_refuse_to_pass_off_scenarios_as_criteria() {
505 let repo = tempfile::tempdir().unwrap();
506 let dir = write_change(repo.path());
507
508 let path = import_change(repo.path(), &dir, None).unwrap();
509 let ticket = std::fs::read_to_string(&path).unwrap();
510 let hints = ticket
511 .split_once("## Acceptance hints")
512 .expect("the template carries an acceptance section")
513 .1;
514
515 assert!(
520 !hints.contains("default to the system preference"),
521 "{hints}"
522 );
523 assert!(hints.contains("cannot fail"), "{hints}");
524 assert!(hints.contains("[1-9][0-9]* passed"), "{hints}");
525 }
526
527 #[test]
528 fn a_directory_without_a_proposal_fails_closed_naming_the_path() {
529 let repo = tempfile::tempdir().unwrap();
530 let dir = repo.path().join("openspec").join("changes").join("empty");
531 std::fs::create_dir_all(&dir).unwrap();
532
533 let error = import_change(repo.path(), &dir, None)
534 .unwrap_err()
535 .to_string();
536 assert!(error.contains("proposal.md"), "{error}");
537 assert!(error.contains(&dir.display().to_string()), "{error}");
538 }
539
540 #[test]
541 fn re_importing_refuses_rather_than_overwriting_operator_edits() {
542 let repo = tempfile::tempdir().unwrap();
543 let dir = write_change(repo.path());
544 let path = import_change(repo.path(), &dir, None).unwrap();
545 std::fs::write(&path, "operator rewrote this ticket").unwrap();
546
547 let error = import_change(repo.path(), &dir, None)
548 .unwrap_err()
549 .to_string();
550 assert!(error.contains("already exists"), "{error}");
551 assert_eq!(
552 std::fs::read_to_string(&path).unwrap(),
553 "operator rewrote this ticket"
554 );
555 }
556}