From bb505f8faf164674530f939c331dd3ff7aa0f5d7 Mon Sep 17 00:00:00 2001 From: "Claude (agent-lpzu)" Date: Thu, 24 Sep 2026 20:48:16 -0700 Subject: [PATCH] feat(import): give each merged file a view filtered to it (improvise-lpzu) A CSV merge now records each file's columns, and the build adds one view per file: File on Filter at that file's item, its own categories seated as a fresh import seats them, the rest on None, and other files' measures hidden. The merged Default view stays the initial view. The headless and wizard paths both carry the sources. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/app.rs | 7 +- crates/improvise-command/src/ui/effect.rs | 1 + crates/improvise-io/src/import/csv_parser.rs | 101 +++++++-- crates/improvise-io/src/import/wizard.rs | 215 +++++++++++++++++-- src/draw.rs | 6 +- src/main.rs | 35 +-- tests/headless_cli.rs | 12 ++ 7 files changed, 322 insertions(+), 55 deletions(-) diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 89406d91..1b3f84c0 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -1224,7 +1224,12 @@ impl App { } pub fn start_import_wizard(&mut self, json: serde_json::Value) { - self.wizard = Some(ImportWizard::new(json)); + self.start_import(crate::import::wizard::ImportPipeline::new(json)); + } + + /// Open the wizard over a pipeline the host built, sources and all. + pub fn start_import(&mut self, pipeline: crate::import::wizard::ImportPipeline) { + self.wizard = Some(ImportWizard::from_pipeline(pipeline)); self.view_state.mode = AppMode::ImportWizard; } diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 0251d4a0..84173f4f 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -1315,6 +1315,7 @@ impl Effect for ImportJsonHeadless { formulas: vec![], // Headless: nobody to ask, so a collision refuses (improvise-cgqt). on_duplicate: DuplicatePolicy::Refuse, + sources: vec![], }; match pipeline.build_model_with_warnings() { diff --git a/crates/improvise-io/src/import/csv_parser.rs b/crates/improvise-io/src/import/csv_parser.rs index 18d353e5..6f2120de 100644 --- a/crates/improvise-io/src/import/csv_parser.rs +++ b/crates/improvise-io/src/import/csv_parser.rs @@ -33,6 +33,12 @@ pub fn parse_csv_with_warnings(path: &Path) -> Result<(Vec, Vec)> /// As [`parse_csv_with_warnings`] but over bytes, so import can come from any /// [`Storage`](crate::storage::Storage) backend, not only a file (improvise-fuj). pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(Vec, Vec)> { + let (_, records, warnings) = parse_csv_bytes(bytes)?; + Ok((records, warnings)) +} + +/// The header (after renaming), the records and the warnings. +fn parse_csv_bytes(bytes: &[u8]) -> Result<(Vec, Vec, Vec)> { let mut reader = ReaderBuilder::new() .has_headers(true) .flexible(true) @@ -112,7 +118,7 @@ pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(Vec, Vec) -> (Vec, Vec) { /// Parse multiple CSV files and merge into a single JSON array. /// Each record gets a "File" field set to the filename stem (e.g., "sales" from "sales.csv"). pub fn merge_csvs(paths: &[impl AsRef]) -> Result> { - let (records, warnings) = merge_csvs_from(&PhysicalFs, paths)?; - for warning in &warnings { + let merged = merge_csvs_from(&PhysicalFs, paths)?; + for warning in &merged.warnings { eprintln!("warning: {warning}"); } - Ok(records) + Ok(merged.records) } -/// As [`merge_csvs`] over any [`Storage`], returning each file's warnings -/// prefixed with its path. Files may have different headers (improvise-lpzu). -pub fn merge_csvs_from( - fs: &dyn Storage, - paths: &[impl AsRef], -) -> Result<(Vec, Vec)> { - let mut all_records = Vec::new(); - let mut all_warnings = Vec::new(); +/// The category every merged record names its source file by. +pub const FILE_CATEGORY: &str = "File"; + +/// One merged file: its `File` item and the columns its header named. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceFile { + pub item: String, + pub columns: Vec, +} + +/// Several CSV files as one record set, with each file's own columns kept. +#[derive(Debug, Clone, PartialEq)] +pub struct Merged { + pub records: Vec, + /// One per distinct `File` item, in the order the files were read. + pub sources: Vec, + /// Each file's parse warnings, prefixed with its path. + pub warnings: Vec, +} + +/// As [`merge_csvs`] over any [`Storage`]. Files may have different headers, +/// and two files with one stem share its item and its columns (improvise-lpzu). +pub fn merge_csvs_from(fs: &dyn Storage, paths: &[impl AsRef]) -> Result { + let mut merged = Merged { + records: Vec::new(), + sources: Vec::new(), + warnings: Vec::new(), + }; for path in paths { let path = path.as_ref(); let stem = path @@ -170,17 +196,41 @@ pub fn merge_csvs_from( .unwrap_or("unknown") .to_string(); let bytes = fs.read(path)?; - let (records, warnings) = parse_csv_bytes_with_warnings(&bytes) + let (headers, records, warnings) = parse_csv_bytes(&bytes) .with_context(|| format!("Failed to parse CSV file: {}", path.display()))?; - all_warnings.extend(warnings.iter().map(|w| format!("{}: {w}", path.display()))); + let at = merged.source_for(&stem); + let columns = &mut merged.sources[at].columns; + for header in headers { + if header != FILE_CATEGORY && !columns.contains(&header) { + columns.push(header); + } + } + merged + .warnings + .extend(warnings.iter().map(|w| format!("{}: {w}", path.display()))); for mut record in records { if let Value::Object(ref mut map) = record { - map.insert("File".to_string(), Value::String(stem.clone())); + map.insert(FILE_CATEGORY.to_string(), Value::String(stem.clone())); + } + merged.records.push(record); + } + } + Ok(merged) +} + +impl Merged { + fn source_for(&mut self, item: &str) -> usize { + match self.sources.iter().position(|s| s.item == item) { + Some(at) => at, + None => { + self.sources.push(SourceFile { + item: item.to_string(), + columns: vec![], + }); + self.sources.len() - 1 } - all_records.push(record); } } - Ok((all_records, all_warnings)) } /// One `improvise import` argument. Whether a path names a folder is the @@ -428,10 +478,21 @@ mod tests { ), ("m/age.csv", "entity,age-months\nsrc/a.rs,2\n"), ]); - let (records, warnings) = merge_csvs_from(&fs, &["m/coupling.csv", "m/age.csv"]).unwrap(); - assert!(warnings.is_empty(), "{warnings:?}"); + let merged = merge_csvs_from(&fs, &["m/coupling.csv", "m/age.csv"]).unwrap(); + assert!(merged.warnings.is_empty(), "{:?}", merged.warnings); + let source = |item: &str, columns: &[&str]| SourceFile { + item: item.to_string(), + columns: columns.iter().map(|c| c.to_string()).collect(), + }; assert_eq!( - records, + merged.sources, + [ + source("coupling", &["entity", "coupled", "degree"]), + source("age", &["entity", "age-months"]), + ] + ); + assert_eq!( + merged.records, [ serde_json::json!({"entity": "src/a.rs", "coupled": "src/b.rs", "degree": 80, "File": "coupling"}), serde_json::json!({"entity": "src/a.rs", "age-months": 2, "File": "age"}), diff --git a/crates/improvise-io/src/import/wizard.rs b/crates/improvise-io/src/import/wizard.rs index 87464ca1..d3283849 100644 --- a/crates/improvise-io/src/import/wizard.rs +++ b/crates/improvise-io/src/import/wizard.rs @@ -7,10 +7,11 @@ use super::analyzer::{ DateComponent, FieldKind, FieldProposal, analyze_records, extract_array_at_path, extract_date_component, find_array_paths, }; +use super::csv_parser::{FILE_CATEGORY, SourceFile}; use crate::formula::parse_formula; use crate::model::MAX_CATEGORIES; use crate::model::cell::{CellKey, CellValue}; -use crate::view::Axis; +use crate::view::{Axis, VIRTUAL_VIEW_NAMES, View}; use crate::workbook::Workbook; // ── Pipeline (no UI state) ──────────────────────────────────────────────────── @@ -29,6 +30,9 @@ pub struct ImportPipeline { pub formulas: Vec, /// What to do with records that share every coordinate (improvise-cgqt). pub on_duplicate: DuplicatePolicy, + /// The files a CSV merge read, each of which gets its own view. Empty for + /// a single file or a JSON import (improvise-lpzu). + pub sources: Vec, } /// Several source records that land on one coordinate tuple (improvise-cgqt). @@ -64,6 +68,7 @@ impl ImportPipeline { model_name: "Imported Model".to_string(), formulas: vec![], on_duplicate: DuplicatePolicy::Refuse, + sources: vec![], }; // Auto-select if root is an array or there is exactly one candidate path. @@ -330,6 +335,10 @@ impl ImportPipeline { } } + for view in source_views(&wb, &self.sources, &plan) { + wb.views.insert(view.name.clone(), view); + } + let mut warnings: Vec = skip_warnings(self.records.len(), skipped_non_objects) .into_iter() .collect(); @@ -341,6 +350,60 @@ impl ImportPipeline { } } +/// One view per merged file, in file order, while `File` is a category. The +/// shape and why Filter: `context/code/formula-and-io.md` (improvise-lpzu). +fn source_views(wb: &Workbook, sources: &[SourceFile], plan: &Plan) -> Vec { + let file_is_a_category = plan.categories.iter().any(|c| c.field == FILE_CATEGORY); + let mut views: Vec = vec![]; + for source in sources.iter().filter(|_| file_is_a_category) { + let taken = |name: &str| { + wb.views.contains_key(name) + || VIRTUAL_VIEW_NAMES.contains(&name) + || views.iter().any(|v| v.name == name) + }; + let name = std::iter::once(source.item.clone()) + .chain((2usize..).map(|n| format!("{} {n}", source.item))) + .find(|name| !taken(name)) + .expect("a free name exists, there being finitely many views"); + views.push(source_view(wb, source, plan, name)); + } + views +} + +/// Filtered to the file; its own categories seated as a fresh import seats +/// them, every other one on None; other files' measures hidden. +fn source_view(wb: &Workbook, source: &SourceFile, plan: &Plan, name: String) -> View { + let has = |field: &str| source.columns.iter().any(|c| c == field); + let own = |cat: &str| { + has(cat) + || plan + .date_extractions + .iter() + .any(|(field, _, _, derived)| derived == cat && has(field)) + }; + let mut view = View::new(name); + for cat in wb.model.categories.values() { + match cat.kind.is_virtual() || own(&cat.name) { + true => view.on_category_added(&cat.name, cat.kind), + false => { + view.category_axes.insert(cat.name.clone(), Axis::None); + } + } + } + view.set_axis("_Measure", Axis::Page); + view.set_axis(FILE_CATEGORY, Axis::Filter); + view.set_page_selection(FILE_CATEGORY, &source.item); + let (mine, theirs): (Vec<&FieldProposal>, Vec<&FieldProposal>) = + plan.measures.iter().partition(|m| has(&m.field)); + for measure in theirs { + view.hide_item("_Measure", &measure.field); + } + if let Some(first) = mine.first() { + view.set_page_selection("_Measure", &first.field); + } + view +} + /// The accepted fields by role, and the date parts derived from them. struct Plan<'a> { categories: Vec<&'a FieldProposal>, @@ -581,8 +644,12 @@ pub struct ImportWizard { impl ImportWizard { pub fn new(raw: Value) -> Self { - let pipeline = ImportPipeline::new(raw); + Self::from_pipeline(ImportPipeline::new(raw)) + } + /// A wizard over a pipeline the caller built, such as a CSV merge that + /// carries its sources (improvise-lpzu). + pub fn from_pipeline(pipeline: ImportPipeline) -> Self { let step = if pipeline.needs_path_selection() { WizardStep::SelectArrayPath } else if pipeline.records.is_empty() { @@ -1068,6 +1135,7 @@ impl ImportWizard { mod tests { use super::{ImportPipeline, collision_warning}; use crate::import::analyzer::FieldKind; + use crate::view::Axis; use serde_json::{Value, json}; #[test] @@ -2096,28 +2164,141 @@ mod tests { assert!(warnings.is_empty(), "{warnings:?}"); } - /// code-maat's coupling and age reports, as a folder import merges them. - fn code_maat_records() -> Value { + /// CSV files as a folder import merges them, sources and all. + fn merged_pipeline(files: &[(&str, &str)]) -> ImportPipeline { use crate::import::csv_parser::merge_csvs_from; use crate::storage::{MemoryFs, Storage}; let fs = MemoryFs::new(); - let files = [ - ( - "maat/age.csv", - "entity,age-months\nsrc/a.rs,2\nsrc/c.rs,7\n", - ), - ( - "maat/coupling.csv", - "entity,coupled,degree,average-revs\nsrc/a.rs,src/b.rs,80,5\nsrc/b.rs,src/c.rs,40,3\n", - ), - ]; for (path, body) in files { fs.write(std::path::Path::new(path), body.as_bytes()) .unwrap(); } - let (records, warnings) = merge_csvs_from(&fs, &files.map(|(p, _)| p)).unwrap(); - assert!(warnings.is_empty(), "{warnings:?}"); - Value::Array(records) + let paths: Vec<&str> = files.iter().map(|(p, _)| *p).collect(); + let merged = merge_csvs_from(&fs, &paths).unwrap(); + assert!(merged.warnings.is_empty(), "{:?}", merged.warnings); + let mut p = ImportPipeline::new(Value::Array(merged.records)); + p.sources = merged.sources; + p + } + + /// code-maat's age and coupling reports. + const CODE_MAAT: [(&str, &str); 2] = [ + ( + "maat/age.csv", + "entity,age-months\nsrc/a.rs,2\nsrc/c.rs,7\n", + ), + ( + "maat/coupling.csv", + "entity,coupled,degree,average-revs\nsrc/a.rs,src/b.rs,80,5\nsrc/b.rs,src/c.rs,40,3\n", + ), + ]; + + fn code_maat_records() -> Value { + Value::Array(merged_pipeline(&CODE_MAAT).records) + } + + /// Every non-empty cell a view's grid draws, row by row. + fn drawn(wb: &crate::workbook::Workbook, view: &str) -> Vec { + use crate::view::GridLayout; + let layout = GridLayout::with_frozen_records(&wb.model, &wb.views[view], None); + (0..layout.row_count()) + .flat_map(|r| (0..layout.col_count()).map(move |c| (r, c))) + .filter_map(|(r, c)| layout.evaluate(&wb.model, &layout.cell_key(r, c)?)) + .filter_map(|v| v.as_f64()) + .collect() + } + + /// improvise-lpzu: each merged file gets a view of its own records and + /// columns. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_merge_gives_each_file_a_view_projected_to_its_columns() { + let (wb, _) = merged_pipeline(&CODE_MAAT) + .build_model_with_warnings() + .unwrap(); + assert_eq!( + wb.views.keys().collect::>(), + ["Default", "age", "coupling"] + ); + assert_eq!(wb.active_view, "Default"); + + let age = &wb.views["age"]; + assert_eq!(age.axis_of("File"), Axis::Filter); + assert_eq!(age.page_selection("File"), Some("age")); + assert_eq!(age.axis_of("entity"), Axis::Row); + assert_eq!(age.axis_of("coupled"), Axis::None); + assert_eq!(age.axis_of("_Measure"), Axis::Page); + assert_eq!(age.page_selection("_Measure"), Some("age-months")); + assert!(age.is_hidden("_Measure", "degree") && age.is_hidden("_Measure", "average-revs")); + assert!(!age.is_hidden("_Measure", "age-months")); + assert_eq!(drawn(&wb, "age"), [2.0, 7.0]); + + let coupling = &wb.views["coupling"]; + assert_eq!(coupling.page_selection("File"), Some("coupling")); + assert_eq!(coupling.axis_of("entity"), Axis::Row); + assert_eq!(coupling.axis_of("coupled"), Axis::Column); + assert_eq!(coupling.page_selection("_Measure"), Some("average-revs")); + assert!(coupling.is_hidden("_Measure", "age-months")); + assert!(!coupling.is_hidden("_Measure", "average-revs")); + assert_eq!(drawn(&wb, "coupling"), [5.0, 3.0]); + } + + /// improvise-lpzu: two files of one schema are told apart by the filter, + /// not by their columns. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_files_view_holds_only_that_files_records() { + let (wb, _) = merged_pipeline(&[ + ("s/q1.csv", "Region,Revenue\nEast,10\nWest,1\n"), + ("s/q2.csv", "Region,Revenue\nEast,20\nWest,2\n"), + ]) + .build_model_with_warnings() + .unwrap(); + assert_eq!(drawn(&wb, "q1"), [10.0, 1.0]); + assert_eq!(drawn(&wb, "q2"), [20.0, 2.0]); + } + + /// improvise-lpzu: a view name is taken from the file only while it is + /// free. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_files_view_name_skips_names_already_held() { + let (wb, _) = merged_pipeline(&[ + ("d/Default.csv", "Region,Revenue\nEast,1\n"), + ("d/_Drill.csv", "Region,Revenue\nEast,2\n"), + ]) + .build_model_with_warnings() + .unwrap(); + assert_eq!( + wb.views.keys().collect::>(), + ["Default", "Default 2", "_Drill 2"] + ); + assert_eq!( + wb.views["Default 2"].page_selection("File"), + Some("Default") + ); + assert_eq!(wb.views["_Drill 2"].page_selection("File"), Some("_Drill")); + } + + /// improvise-lpzu: the per-file views persist, and in the same order on + /// every import. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn per_file_views_survive_a_round_trip_and_repeat_byte_for_byte() { + use crate::persistence::{format_md, parse_md}; + let build = || { + let (wb, _) = merged_pipeline(&CODE_MAAT) + .build_model_with_warnings() + .unwrap(); + format_md(&wb) + }; + let text = build(); + assert_eq!(build(), text); + let reread = parse_md(&text).unwrap(); + assert_eq!(format_md(&reread), text); + assert_eq!( + reread.views.keys().collect::>(), + ["Default", "age", "coupling"] + ); + assert_eq!(reread.views["age"].axis_of("File"), Axis::Filter); + assert_eq!(reread.views["age"].page_selection("File"), Some("age")); + assert_eq!(drawn(&reread, "coupling"), [5.0, 3.0]); } /// improvise-lpzu: a column a file lacks is absent from its records, never diff --git a/src/draw.rs b/src/draw.rs index 65a8eb52..158e674f 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -58,7 +58,7 @@ impl<'a> Drop for TuiContext<'a> { pub fn run_tui( workbook: crate::workbook::Workbook, file_path: Option, - import_value: Option, + import: Option, ) -> Result<()> { let mut stdout = io::stdout(); let mut tui_context = TuiContext::enter(&mut stdout)?; @@ -68,8 +68,8 @@ pub fn run_tui( app.term_height = h; } - if let Some(json) = import_value { - app.start_import_wizard(json); + if let Some(pipeline) = import { + app.start_import(pipeline); } else if app.is_empty_model() { app.view_state.mode = AppMode::Help; } diff --git a/src/main.rs b/src/main.rs index 04dc835d..cdd3b83f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,7 +144,7 @@ impl Runnable for ImportArgs { if self.files.is_empty() { anyhow::bail!("No files specified for import"); } - let import_value = get_import_data(&self.files) + let pipeline = get_import_data(&self.files) .ok_or_else(|| anyhow::anyhow!("Failed to parse import files"))?; let config = ImportConfig { @@ -160,9 +160,9 @@ impl Runnable for ImportArgs { }; if self.no_wizard { - run_headless_import(import_value, &config, self.output, model_file) + run_headless_import(pipeline, &config, self.output, model_file) } else { - run_wizard_import(import_value, &config, model_file) + run_wizard_import(pipeline, &config, model_file) } } } @@ -202,7 +202,7 @@ fn parse_colon_pairs(args: &[String]) -> Vec<(String, String)> { .collect() } -use import::wizard::DuplicatePolicy; +use import::wizard::{DuplicatePolicy, ImportPipeline}; fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, config: &ImportConfig) { use import::analyzer::{DateComponent, FieldKind}; @@ -289,12 +289,11 @@ fn apply_axis_overrides(wb: &mut Workbook, axes: &[(String, String)]) { } fn run_headless_import( - import_value: Value, + mut pipeline: ImportPipeline, config: &ImportConfig, output: Option, model_file: Option, ) -> Result<()> { - let mut pipeline = import::wizard::ImportPipeline::new(import_value); apply_config_to_pipeline(&mut pipeline, config); if pipeline.on_duplicate == DuplicatePolicy::Refuse { let collisions = pipeline.collisions(); @@ -318,19 +317,19 @@ fn run_headless_import( } fn run_wizard_import( - import_value: Value, + pipeline: ImportPipeline, _config: &ImportConfig, model_file: Option, ) -> Result<()> { let workbook = get_initial_workbook(&model_file)?; // The wizard configures itself inside the TUI. // TODO: pass `_config` to it for pre-population. - run_tui(workbook, model_file, Some(import_value)) + run_tui(workbook, model_file, Some(pipeline)) } // ── Import data loading ────────────────────────────────────────────────────── -fn get_import_data(args: &[PathBuf]) -> Option { +fn get_import_data(args: &[PathBuf]) -> Option { use improvise::import::csv_parser::{ImportPath, expand_import_paths}; // Whether an argument is a folder is the one filesystem question asked here. let classified: Vec = args @@ -355,10 +354,18 @@ fn get_import_data(args: &[PathBuf]) -> Option { eprintln!("Multi-file import only supports CSV files"); return None; } - match crate::import::csv_parser::merge_csvs(paths) { - Ok(records) => Some(Value::Array(records)), + let fs = improvise_io::storage::PhysicalFs; + match crate::import::csv_parser::merge_csvs_from(&fs, paths) { + Ok(merged) => { + for warning in &merged.warnings { + eprintln!("warning: {warning}"); + } + let mut pipeline = ImportPipeline::new(Value::Array(merged.records)); + pipeline.sources = merged.sources; + Some(pipeline) + } Err(e) => { - eprintln!("CSV merge error: {e}"); + eprintln!("CSV merge error: {e:#}"); None } } @@ -372,7 +379,7 @@ fn get_import_data(args: &[PathBuf]) -> Option { Ok(content) => { if csv_path_p(path) { match crate::import::csv_parser::parse_csv(path) { - Ok(records) => Some(Value::Array(records)), + Ok(records) => Some(ImportPipeline::new(Value::Array(records))), Err(e) => { eprintln!("CSV parse error: {e}"); None @@ -384,7 +391,7 @@ fn get_import_data(args: &[PathBuf]) -> Option { eprintln!("JSON parse error: {e}"); None } - Ok(json) => Some(json), + Ok(json) => Some(ImportPipeline::new(json)), } } } diff --git a/tests/headless_cli.rs b/tests/headless_cli.rs index 4d4bdeec..b1f33267 100644 --- a/tests/headless_cli.rs +++ b/tests/headless_cli.rs @@ -653,6 +653,18 @@ fn a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte( assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}"); } assert!(!doc.contains("README"), "{doc}"); + let views: Vec<&str> = doc.lines().filter(|l| l.starts_with("## View: ")).collect(); + assert_eq!( + views, + [ + "## View: Default", + "## View: abs-churn", + "## View: age", + "## View: coupling" + ] + ); + assert!(doc.lines().any(|l| l == "File: filter, coupling"), "{doc}"); + assert!(doc.contains("Initial View: Default"), "{doc}"); // The output now sits in the folder; a second run must not read it. let second = run_import(&dir, &out_path, &[]);