Skip to main content

ferro_cli/commands/
ai_make.rs

1//! `ferro ai:make <description>` — AI-powered ServiceDef generator.
2//!
3//! Thin CLI wrapper over `ferro_mcp::tools::ai_scaffold::scaffold_core`. All
4//! introspection, relevance filtering, prompt assembly, LLM completion, and
5//! validation run inside the core. This module owns the tokio runtime bridge,
6//! console output, file write, and process exit — writing exactly one
7//! `src/projections/<snake>.rs` file on success.
8
9#[cfg(feature = "projections")]
10use ferro_projections::{
11    ActionDef, Cardinality, DataType, FieldDef, FieldMeaning, Intent, IntentHint, RelationshipDef,
12    ServiceDef, StateMachine,
13};
14
15#[cfg(feature = "projections")]
16use std::path::{Path, PathBuf};
17
18// ---------------------------------------------------------------------------
19// ServiceDef → Rust builder source emitter
20// ---------------------------------------------------------------------------
21
22/// Emit a `ServiceDef` as idiomatic Rust builder source.
23///
24/// Produces a `pub fn <snake_name>_service() -> ServiceDef { ... }` function
25/// ready to drop into `src/projections/<snake_name>.rs`.
26///
27/// The function name is derived from the snake_case-validated identifier (same
28/// as the file/module name) so the generated code is always valid Rust that
29/// passes `clippy -D warnings` without a `non_snake_case` warning.
30#[cfg(feature = "projections")]
31pub(crate) fn emit_service_def_source(service: &ServiceDef) -> String {
32    let name = &service.name;
33    // Use the same snake_case name that resolve_projection_path uses for the
34    // file name — ensures the function identifier is always valid snake_case.
35    let snake_name = crate::naming::to_snake_case(name);
36    let fn_name = format!("{snake_name}_service");
37
38    // Collect which types are actually used so the `use ferro::{ ... }` header
39    // imports only what is needed.
40    let mut uses_action = false;
41    let mut uses_guard = false;
42    let mut uses_relationship = false;
43    let mut uses_state_machine = false;
44    let mut uses_intent_hint = false;
45    let mut uses_intent = false;
46    let mut uses_cardinality = false;
47
48    if !service.actions.is_empty() {
49        uses_action = true;
50    }
51    if !service.guards.is_empty() {
52        uses_guard = true;
53    }
54    if !service.relationships.is_empty() {
55        uses_relationship = true;
56        uses_cardinality = true;
57    }
58    if service.state_machine.is_some() {
59        uses_state_machine = true;
60    }
61    if !service.intent_hints.is_empty() {
62        uses_intent_hint = true;
63        uses_intent = true;
64    }
65
66    // Build the `use ferro::{...}` imports line
67    let mut use_items: Vec<&str> = vec!["DataType", "FieldMeaning", "ServiceDef"];
68    if uses_action {
69        use_items.push("ActionDef");
70    }
71    if uses_guard {
72        use_items.push("GuardDef");
73    }
74    if uses_relationship {
75        use_items.push("RelationshipDef");
76    }
77    if uses_cardinality {
78        use_items.push("Cardinality");
79    }
80    if uses_state_machine {
81        use_items.push("StateDef");
82        use_items.push("StateMachine");
83        use_items.push("Transition");
84    }
85    if uses_intent_hint {
86        use_items.push("IntentHint");
87    }
88    if uses_intent {
89        use_items.push("Intent");
90    }
91    use_items.sort_unstable();
92    use_items.dedup();
93
94    let use_line = format!("use ferro::{{{}}};\n", use_items.join(", "));
95
96    // Build the builder chain lines
97    let mut chain: Vec<String> = Vec::new();
98    chain.push(format!("    ServiceDef::new({name:?})"));
99
100    if let Some(ref dn) = service.display_name {
101        chain.push(format!("        .display_name({dn:?})"));
102    }
103    if let Some(ref desc) = service.description {
104        chain.push(format!("        .description({desc:?})"));
105    }
106
107    for field in &service.fields {
108        let builder_method = field_builder_method(field);
109        let dt = emit_data_type(&field.data_type);
110        let meaning = emit_field_meaning(&field.meaning);
111        chain.push(format!(
112            "        .{builder_method}({:?}, {dt}, {meaning})",
113            field.name
114        ));
115    }
116
117    for guard in &service.guards {
118        chain.push(format!("        .guard(GuardDef::new({:?}))", guard.name));
119    }
120
121    for action in &service.actions {
122        chain.push(emit_action_def(action));
123    }
124
125    for rel in &service.relationships {
126        chain.push(emit_relationship_def(rel));
127    }
128
129    for hint in &service.intent_hints {
130        chain.push(emit_intent_hint(hint));
131    }
132
133    if let Some(ref sm) = service.state_machine {
134        chain.push(emit_state_machine(sm));
135    }
136
137    let builder_body = chain.join("\n");
138
139    format!(
140        "{use_line}\n/// Build the {name} service projection.\npub fn {fn_name}() -> ServiceDef {{\n{builder_body}\n}}\n"
141    )
142}
143
144/// Select the builder method name based on FieldDef flags.
145///
146/// Priority order (first match wins):
147/// - `!readable` → write_only_field
148/// - `!writable` → read_only_field
149/// - `is_list`   → list_field
150/// - `!required` → optional_field
151/// - default     → field
152#[cfg(feature = "projections")]
153fn field_builder_method(field: &FieldDef) -> &'static str {
154    if !field.readable {
155        "write_only_field"
156    } else if !field.writable {
157        "read_only_field"
158    } else if field.is_list {
159        "list_field"
160    } else if !field.required {
161        "optional_field"
162    } else {
163        "field"
164    }
165}
166
167/// Emit the Rust identifier for a DataType variant.
168///
169/// REQUIRED: explicit match — DataType has `#[serde(rename_all = "snake_case")]`
170/// so `serde_json::to_string(&DataType::DateTime)` → `"date_time"`, not `"DateTime"`.
171#[cfg(feature = "projections")]
172fn emit_data_type(dt: &DataType) -> &'static str {
173    match dt {
174        DataType::String => "DataType::String",
175        DataType::Integer => "DataType::Integer",
176        DataType::Float => "DataType::Float",
177        DataType::Boolean => "DataType::Boolean",
178        DataType::DateTime => "DataType::DateTime",
179        DataType::Date => "DataType::Date",
180        DataType::Json => "DataType::Json",
181        DataType::Binary => "DataType::Binary",
182        DataType::Uuid => "DataType::Uuid",
183        DataType::Enum => "DataType::Enum",
184    }
185}
186
187/// Emit the Rust expression for a FieldMeaning value.
188///
189/// REQUIRED: explicit match for all 18 known variants — `Custom(String)` uses
190/// `#[serde(untagged)]` so serde cannot distinguish it from known variants at
191/// the JSON level. We must check all known variants first.
192#[cfg(feature = "projections")]
193fn emit_field_meaning(m: &FieldMeaning) -> String {
194    match m {
195        FieldMeaning::Identifier => "FieldMeaning::Identifier".into(),
196        FieldMeaning::ForeignKey => "FieldMeaning::ForeignKey".into(),
197        FieldMeaning::EntityName => "FieldMeaning::EntityName".into(),
198        FieldMeaning::Email => "FieldMeaning::Email".into(),
199        FieldMeaning::Phone => "FieldMeaning::Phone".into(),
200        FieldMeaning::Url => "FieldMeaning::Url".into(),
201        FieldMeaning::ImageUrl => "FieldMeaning::ImageUrl".into(),
202        FieldMeaning::Money => "FieldMeaning::Money".into(),
203        FieldMeaning::Percentage => "FieldMeaning::Percentage".into(),
204        FieldMeaning::Quantity => "FieldMeaning::Quantity".into(),
205        FieldMeaning::Status => "FieldMeaning::Status".into(),
206        FieldMeaning::Category => "FieldMeaning::Category".into(),
207        FieldMeaning::Boolean => "FieldMeaning::Boolean".into(),
208        FieldMeaning::FreeText => "FieldMeaning::FreeText".into(),
209        FieldMeaning::CreatedAt => "FieldMeaning::CreatedAt".into(),
210        FieldMeaning::UpdatedAt => "FieldMeaning::UpdatedAt".into(),
211        FieldMeaning::DateTime => "FieldMeaning::DateTime".into(),
212        FieldMeaning::Sensitive => "FieldMeaning::Sensitive".into(),
213        FieldMeaning::Custom(s) => format!(r#"FieldMeaning::Custom({s:?}.into())"#),
214    }
215}
216
217/// Emit an ActionDef builder chain line.
218#[cfg(feature = "projections")]
219fn emit_action_def(action: &ActionDef) -> String {
220    let mut parts = vec![format!("ActionDef::new({:?})", action.name)];
221    if let Some(ref dn) = action.display_name {
222        parts.push(format!(".display_name({dn:?})"));
223    }
224    if let Some(ref desc) = action.description {
225        parts.push(format!(".description({desc:?})"));
226    }
227    for pre in &action.preconditions {
228        parts.push(format!(".precondition({pre:?})"));
229    }
230    for eff in &action.effects {
231        parts.push(format!(".effect({eff:?})"));
232    }
233    if let Some(ref trigger) = action.transition_trigger {
234        parts.push(format!(".transition_trigger({trigger:?})"));
235    }
236    format!("        .action({})", parts.join(""))
237}
238
239/// Emit a RelationshipDef builder chain line.
240#[cfg(feature = "projections")]
241fn emit_relationship_def(rel: &RelationshipDef) -> String {
242    let card = emit_cardinality(&rel.cardinality);
243    let mut parts = vec![format!(
244        "RelationshipDef::new({:?}, {:?}, {card})",
245        rel.name, rel.target
246    )];
247    if let Some(ref fk) = rel.foreign_key {
248        parts.push(format!(".foreign_key({fk:?})"));
249    }
250    if let Some(ref inv) = rel.inverse {
251        parts.push(format!(".inverse({inv:?})"));
252    }
253    format!("        .relationship({})", parts.join(""))
254}
255
256/// Emit the Rust identifier for a Cardinality variant.
257///
258/// REQUIRED: explicit match — Cardinality has `#[serde(rename_all = "snake_case")]`.
259#[cfg(feature = "projections")]
260fn emit_cardinality(card: &Cardinality) -> &'static str {
261    match card {
262        Cardinality::OneToOne => "Cardinality::OneToOne",
263        Cardinality::OneToMany => "Cardinality::OneToMany",
264        Cardinality::ManyToOne => "Cardinality::ManyToOne",
265        Cardinality::ManyToMany => "Cardinality::ManyToMany",
266    }
267}
268
269/// Emit an IntentHint builder line.
270///
271/// IntentHint is an externally-tagged enum: Primary(Intent) | Exclude(Intent).
272#[cfg(feature = "projections")]
273fn emit_intent_hint(hint: &IntentHint) -> String {
274    match hint {
275        IntentHint::Primary(intent) => {
276            format!(
277                "        .intent_hint(IntentHint::Primary({}))",
278                emit_intent(intent)
279            )
280        }
281        IntentHint::Exclude(intent) => {
282            format!(
283                "        .intent_hint(IntentHint::Exclude({}))",
284                emit_intent(intent)
285            )
286        }
287    }
288}
289
290/// Emit the Rust expression for an Intent value.
291///
292/// REQUIRED: explicit match — Intent has `#[serde(rename_all = "snake_case")]`
293/// and `Custom(String)` uses `#[serde(untagged)]`.
294#[cfg(feature = "projections")]
295fn emit_intent(intent: &Intent) -> String {
296    match intent {
297        Intent::Browse => "Intent::Browse".into(),
298        Intent::Focus => "Intent::Focus".into(),
299        Intent::Collect => "Intent::Collect".into(),
300        Intent::Process => "Intent::Process".into(),
301        Intent::Summarize => "Intent::Summarize".into(),
302        Intent::Analyze => "Intent::Analyze".into(),
303        Intent::Track => "Intent::Track".into(),
304        Intent::Custom(s) => format!(r#"Intent::Custom({s:?}.into())"#),
305    }
306}
307
308/// Emit a StateMachine builder chain as a multi-line block for `.state_machine(...)`.
309#[cfg(feature = "projections")]
310fn emit_state_machine(sm: &StateMachine) -> String {
311    let mut lines = vec![format!(
312        "        .state_machine(StateMachine::new({:?})",
313        sm.name
314    )];
315
316    if let Some(ref dn) = sm.display_name {
317        lines.push(format!("            .display_name({dn:?})"));
318    }
319    if !sm.initial_state.is_empty() {
320        lines.push(format!("            .initial({:?})", sm.initial_state));
321    }
322    for state in &sm.states {
323        let mut s = format!("            .state(StateDef::new({:?})", state.name);
324        if let Some(ref dn) = state.display_name {
325            s.push_str(&format!(".display_name({dn:?})"));
326        }
327        if state.is_final {
328            s.push_str(".final_state()");
329        }
330        s.push(')');
331        lines.push(s);
332    }
333    for t in &sm.transitions {
334        let mut tr = format!(
335            "            .transition(Transition::new({:?}, {:?}, {:?})",
336            t.from, t.event, t.to
337        );
338        if let Some(ref g) = t.guard {
339            tr.push_str(&format!(".guard({g:?})"));
340        }
341        tr.push(')');
342        lines.push(tr);
343    }
344    lines.push("        )".to_string());
345    lines.join("\n")
346}
347
348// ---------------------------------------------------------------------------
349// Path sanitization helpers
350// ---------------------------------------------------------------------------
351
352/// Resolve and sanitize a projection file path from a raw service name.
353///
354/// Converts the name to snake_case, validates it is a safe Rust identifier
355/// (rejects path traversal, absolute paths, non-identifier chars), then
356/// joins it under the fixed `src/projections/` base.
357#[cfg(feature = "projections")]
358pub(crate) fn resolve_projection_path(raw: &str) -> Result<PathBuf, String> {
359    let snake = crate::naming::to_snake_case(raw);
360    if !crate::naming::is_valid_identifier(&snake) {
361        return Err(format!(
362            "'{raw}' is not a valid projection name (must be a Rust identifier after snake_case conversion)"
363        ));
364    }
365    Ok(Path::new("src/projections").join(format!("{snake}.rs")))
366}
367
368// ---------------------------------------------------------------------------
369// Output result type for dry-run / file-write abstraction
370// ---------------------------------------------------------------------------
371
372#[cfg(feature = "projections")]
373pub(crate) enum OutputResult {
374    /// Dry-run: pretty-printed ServiceDef JSON, no files written.
375    DryRun(String),
376    /// File written successfully at the given path.
377    Written(PathBuf),
378    /// Projection file already existed; skipped.
379    AlreadyExists(PathBuf),
380}
381
382/// Render the ServiceDef output: either pretty JSON (dry_run) or write to disk.
383///
384/// `out_dir` is the project root; files are written under `out_dir/src/projections/`.
385#[cfg(feature = "projections")]
386pub(crate) fn render_output(
387    service: &ServiceDef,
388    dry_run: bool,
389    out_dir: &Path,
390) -> Result<OutputResult, String> {
391    if dry_run {
392        let json = serde_json::to_string_pretty(service)
393            .map_err(|e| format!("Failed to serialize ServiceDef: {e}"))?;
394        return Ok(OutputResult::DryRun(json));
395    }
396
397    // Resolve the path relative to out_dir
398    let rel = resolve_projection_path(&service.name)?;
399    let projection_file = out_dir.join(&rel);
400
401    if projection_file.exists() {
402        return Ok(OutputResult::AlreadyExists(projection_file));
403    }
404
405    let projections_dir = projection_file
406        .parent()
407        .ok_or_else(|| "cannot determine projections directory".to_string())?;
408
409    std::fs::create_dir_all(projections_dir)
410        .map_err(|e| format!("Failed to create projections directory: {e}"))?;
411
412    let content = emit_service_def_source(service);
413    std::fs::write(&projection_file, &content)
414        .map_err(|e| format!("Failed to write projection file: {e}"))?;
415
416    // Register in mod.rs
417    let file_stem = projection_file
418        .file_stem()
419        .and_then(|s| s.to_str())
420        .unwrap_or(&service.name);
421    let mod_file = projections_dir.join("mod.rs");
422
423    if mod_file.exists() {
424        let mod_content = std::fs::read_to_string(&mod_file).unwrap_or_default();
425        let pub_mod_decl = format!("pub mod {file_stem};");
426        if !mod_content.contains(&pub_mod_decl) {
427            crate::commands::make_projection::update_mod_file(&mod_file, file_stem)
428                .map_err(|e| format!("Failed to update mod.rs: {e}"))?;
429        }
430    } else {
431        std::fs::write(&mod_file, format!("pub mod {file_stem};\n"))
432            .map_err(|e| format!("Failed to create mod.rs: {e}"))?;
433    }
434
435    Ok(OutputResult::Written(projection_file))
436}
437
438// ---------------------------------------------------------------------------
439// Command entry point
440// ---------------------------------------------------------------------------
441
442/// Run the `ferro ai:make <description>` command.
443///
444/// Thin wrapper over `ferro_mcp::tools::ai_scaffold::scaffold_core`. All
445/// introspection, relevance filtering, prompt assembly, LLM completion, and
446/// validation run inside the core. The CLI bridge (tokio runtime, console
447/// output, file write, process exit) stays here.
448/// `--dry-run` prints the ServiceDef as pretty JSON and writes nothing.
449#[cfg(feature = "projections")]
450pub fn run(description: String, dry_run: bool) {
451    use console::style;
452
453    // Tokio runtime bridge (ferro-cli main is sync; the core is async).
454    let rt = match tokio::runtime::Runtime::new() {
455        Ok(r) => r,
456        Err(e) => {
457            eprintln!(
458                "{} Failed to create tokio runtime: {e}",
459                style("Error:").red().bold()
460            );
461            std::process::exit(1);
462        }
463    };
464
465    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
466
467    println!("{} Generating ServiceDef via AI...", style("⏳").cyan());
468
469    let service = match rt.block_on(ferro_mcp::tools::ai_scaffold::scaffold_core(
470        &description,
471        &cwd,
472    )) {
473        Ok(s) => s,
474        Err(e) => {
475            eprintln!("{} {e}", style("Error:").red().bold());
476            std::process::exit(1);
477        }
478    };
479
480    match render_output(&service, dry_run, &cwd) {
481        Ok(OutputResult::DryRun(json)) => {
482            println!("{json}");
483        }
484        Ok(OutputResult::Written(path)) => {
485            println!("{} Created {}", style("✓").green(), path.display());
486        }
487        Ok(OutputResult::AlreadyExists(path)) => {
488            eprintln!(
489                "{} Projection already exists at {}. Delete it first or use a different name.",
490                style("Info:").yellow().bold(),
491                path.display()
492            );
493            std::process::exit(0);
494        }
495        Err(e) => {
496            eprintln!("{} {e}", style("Error:").red().bold());
497            std::process::exit(1);
498        }
499    }
500}
501
502// ---------------------------------------------------------------------------
503// Tests
504// ---------------------------------------------------------------------------
505
506#[cfg(all(test, feature = "projections"))]
507mod tests {
508    use super::*;
509    use ferro_projections::{
510        ActionDef, Cardinality, DataType, FieldMeaning, GuardDef, Intent, IntentHint,
511        RelationshipDef, ServiceDef, StateDef, StateMachine, Transition,
512    };
513    use tempfile::TempDir;
514
515    // ---- Emitter unit tests ----
516
517    #[test]
518    fn emit_data_type_datetime_is_not_snake_case() {
519        assert_eq!(emit_data_type(&DataType::DateTime), "DataType::DateTime");
520        // Must NOT produce "DataType::date_time" (what serde would give)
521        assert_ne!(emit_data_type(&DataType::DateTime), "DataType::date_time");
522    }
523
524    #[test]
525    fn emit_field_meaning_known_variant() {
526        assert_eq!(
527            emit_field_meaning(&FieldMeaning::Money),
528            "FieldMeaning::Money"
529        );
530    }
531
532    #[test]
533    fn emit_field_meaning_custom_variant() {
534        assert_eq!(
535            emit_field_meaning(&FieldMeaning::Custom("sku".into())),
536            r#"FieldMeaning::Custom("sku".into())"#
537        );
538    }
539
540    #[test]
541    fn emit_field_meaning_description_escaping() {
542        // A custom meaning whose value contains a double-quote
543        let m = FieldMeaning::Custom(r#"has "quotes""#.into());
544        let emitted = emit_field_meaning(&m);
545        // {:?} debug-formats with escaped quotes
546        assert!(emitted.contains(r#"\"quotes\""#), "got: {emitted}");
547    }
548
549    #[test]
550    fn emitter_round_trip() {
551        // Build a representative ServiceDef
552        let service = ServiceDef::new("test_service")
553            .display_name("Test Service")
554            .description("A test service with \"quoted\" description")
555            .field("id", DataType::Integer, FieldMeaning::Identifier)
556            .optional_field("note", DataType::String, FieldMeaning::FreeText)
557            .field("sku", DataType::String, FieldMeaning::Custom("sku".into()))
558            .guard(GuardDef::new("authenticated"))
559            .action(ActionDef::new("create"))
560            .relationship(RelationshipDef::new(
561                "customer",
562                "customer",
563                Cardinality::ManyToOne,
564            ))
565            .intent_hint(IntentHint::Primary(Intent::Browse))
566            .state_machine(
567                StateMachine::new("lifecycle")
568                    .initial("active")
569                    .state(StateDef::new("active"))
570                    .state(StateDef::new("closed").final_state())
571                    .transition(Transition::new("active", "close", "closed")),
572            );
573
574        let source = emit_service_def_source(&service);
575
576        // Check function signature
577        assert!(
578            source.contains("pub fn test_service_service() -> ServiceDef"),
579            "missing function signature\nsource:\n{source}"
580        );
581        // Check ServiceDef::new call
582        assert!(
583            source.contains(r#"ServiceDef::new("test_service")"#),
584            "source:\n{source}"
585        );
586        // Check data type (must NOT be snake_case)
587        assert!(source.contains("DataType::Integer"), "source:\n{source}");
588        // Check field meaning
589        assert!(
590            source.contains("FieldMeaning::Identifier"),
591            "source:\n{source}"
592        );
593        // Check custom meaning
594        assert!(
595            source.contains(r#"FieldMeaning::Custom("sku".into())"#),
596            "source:\n{source}"
597        );
598        // Check guard
599        assert!(source.contains("GuardDef::new("), "source:\n{source}");
600        // Check action
601        assert!(source.contains("ActionDef::new("), "source:\n{source}");
602        // Check relationship
603        assert!(
604            source.contains("RelationshipDef::new("),
605            "source:\n{source}"
606        );
607        // Check intent hint
608        assert!(source.contains("IntentHint"), "source:\n{source}");
609        // Check state machine
610        assert!(source.contains("StateMachine"), "source:\n{source}");
611    }
612
613    #[test]
614    fn emitter_pascal_case_name_produces_snake_case_function() {
615        // WR-01: LLM may return PascalCase service names. The emitter must use
616        // snake_case for the function name to avoid clippy non_snake_case warnings.
617        let service =
618            ServiceDef::new("OrderItem").field("id", DataType::Integer, FieldMeaning::Identifier);
619        let source = emit_service_def_source(&service);
620        // Function name must be snake_case (order_item_service), NOT PascalCase
621        assert!(
622            source.contains("pub fn order_item_service() -> ServiceDef"),
623            "function name must be snake_case\nsource:\n{source}"
624        );
625        assert!(
626            !source.contains("pub fn OrderItem_service"),
627            "function name must NOT be PascalCase\nsource:\n{source}"
628        );
629        // ServiceDef::new preserves the original name (runtime identity, not identifier)
630        assert!(
631            source.contains(r#"ServiceDef::new("OrderItem")"#),
632            "ServiceDef::new must use the original name\nsource:\n{source}"
633        );
634    }
635
636    // ---- Path sanitization tests ----
637
638    #[test]
639    fn ai_make_rejects_path_traversal() {
640        assert!(
641            resolve_projection_path("../../etc/passwd").is_err(),
642            "path traversal should be rejected"
643        );
644    }
645
646    #[test]
647    fn ai_make_accepts_valid_name() {
648        let path = resolve_projection_path("Order").expect("valid name should succeed");
649        assert!(path.ends_with("src/projections/order.rs"), "got: {path:?}");
650    }
651
652    // ---- Dry-run test ----
653
654    #[test]
655    fn dry_run_no_file_write() {
656        let dir = TempDir::new().expect("tempdir");
657        let service =
658            ServiceDef::new("preview").field("id", DataType::Integer, FieldMeaning::Identifier);
659
660        let result = render_output(&service, true, dir.path()).expect("dry-run should not error");
661
662        match result {
663            OutputResult::DryRun(json) => {
664                assert!(json.contains("preview"), "JSON should contain service name");
665                // No files written
666                let proj_file = dir.path().join("src/projections/preview.rs");
667                assert!(!proj_file.exists(), "dry-run must not write files");
668            }
669            _ => panic!("expected DryRun result"),
670        }
671    }
672}