lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

dto_roots.rs (35940B)


      1 use std::fs;
      2 use std::io::{self, ErrorKind, Write};
      3 use std::path::{Component, Path, PathBuf};
      4 
      5 use dto_bindgen_core::{
      6     Config, RootDiscoveryConfig, RootDiscoveryMode, generate_root_module, scan_rust_source,
      7 };
      8 use tempfile::NamedTempFile;
      9 
     10 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     11 enum Mode {
     12     Check,
     13     Write,
     14 }
     15 
     16 #[derive(Debug)]
     17 struct GeneratedRootFile {
     18     path: PathBuf,
     19     display_path: String,
     20     contents: String,
     21 }
     22 
     23 struct StagedRootFile {
     24     path: PathBuf,
     25     display_path: String,
     26     temporary: NamedTempFile,
     27     original: Option<OriginalFileState>,
     28 }
     29 
     30 struct OriginalRootFile {
     31     path: PathBuf,
     32     display_path: String,
     33     contents: Option<Vec<u8>>,
     34     permissions: Option<fs::Permissions>,
     35 }
     36 
     37 struct OriginalFileState {
     38     contents: Vec<u8>,
     39     permissions: fs::Permissions,
     40 }
     41 
     42 type PersistFailure = (NamedTempFile, io::Error);
     43 
     44 pub(crate) fn run(args: &[String], workspace_root: &Path) -> Result<(), String> {
     45     let mode = match args {
     46         [arg] if arg == "--check" => Mode::Check,
     47         [arg] if arg == "--write" => Mode::Write,
     48         _ => return Err("usage: cargo xtask dto-roots --check|--write".to_owned()),
     49     };
     50     execute(workspace_root, mode)
     51 }
     52 
     53 pub(crate) fn check(workspace_root: &Path) -> Result<(), String> {
     54     execute(workspace_root, Mode::Check)
     55 }
     56 
     57 fn execute(workspace_root: &Path, mode: Mode) -> Result<(), String> {
     58     let config_relative_path = "dto_bindgen.toml";
     59     let config_path = workspace_path(workspace_root, config_relative_path)?;
     60     validate_existing_regular_file(workspace_root, config_relative_path, "DTO root authority")?;
     61     let config = Config::from_toml_path(&config_path).map_err(|error| {
     62         format!(
     63             "failed to load DTO root authority `{}`: {error}",
     64             config_path.display()
     65         )
     66     })?;
     67     let generated = generate_configured_roots(workspace_root, &config)?;
     68     validate_output_paths(workspace_root, &generated)?;
     69 
     70     match mode {
     71         Mode::Check => check_generated_roots(&generated),
     72         Mode::Write => write_generated_roots(&generated),
     73     }
     74 }
     75 
     76 fn generate_configured_roots(
     77     workspace_root: &Path,
     78     config: &Config,
     79 ) -> Result<Vec<GeneratedRootFile>, String> {
     80     let mut generated = Vec::new();
     81     if config.root_discovery.mode == RootDiscoveryMode::SourceManifest {
     82         generated.push(generate_discovery_roots(
     83             workspace_root,
     84             config,
     85             "top-level root discovery",
     86             &config.root_discovery,
     87         )?);
     88     }
     89     for package in &config.packages {
     90         if package.root_discovery.mode != RootDiscoveryMode::SourceManifest {
     91             continue;
     92         }
     93         let discovery = RootDiscoveryConfig {
     94             mode: package.root_discovery.mode,
     95             source_files: package.root_discovery.source_files.clone(),
     96             root_module_file: package.root_discovery.root_module_file.clone(),
     97         };
     98         generated.push(generate_discovery_roots(
     99             workspace_root,
    100             config,
    101             &format!("package `{}`", package.key),
    102             &discovery,
    103         )?);
    104     }
    105 
    106     if generated.is_empty() {
    107         return Err("dto_bindgen.toml defines no source-manifest DTO roots".to_owned());
    108     }
    109 
    110     generated.sort_by(|left, right| left.display_path.cmp(&right.display_path));
    111     Ok(generated)
    112 }
    113 
    114 fn generate_discovery_roots(
    115     workspace_root: &Path,
    116     config: &Config,
    117     authority_label: &str,
    118     discovery: &RootDiscoveryConfig,
    119 ) -> Result<GeneratedRootFile, String> {
    120     let mut inventories = Vec::with_capacity(discovery.source_files.len());
    121     for source_file in &discovery.source_files {
    122         let path = workspace_path(workspace_root, source_file)?;
    123         validate_existing_regular_file(workspace_root, source_file, "DTO source")?;
    124         let input = fs::read_to_string(&path).map_err(|error| {
    125             format!("failed to read DTO source `{source_file}` for {authority_label}: {error}")
    126         })?;
    127         let mut inventory =
    128             scan_rust_source(source_file.clone(), &input).map_err(|error| error.to_string())?;
    129         inventory.source_file = canonical_dto_source_path(source_file).to_owned();
    130         inventories.push(inventory);
    131     }
    132 
    133     let mut package_config = config.clone();
    134     package_config.root_discovery = discovery.clone();
    135     let module = generate_root_module(&package_config, &inventories)
    136         .map_err(|error| format!("failed to generate DTO roots for {authority_label}: {error}"))?;
    137     let path = workspace_path(workspace_root, &module.path)?;
    138 
    139     Ok(GeneratedRootFile {
    140         path,
    141         display_path: module.path,
    142         contents: module.contents,
    143     })
    144 }
    145 
    146 /// Returns the crate-visible source route used by generated root descriptors.
    147 ///
    148 /// `radroots_event` intentionally keeps several implementation files at the
    149 /// crate source root while exposing them through singular domain facades with
    150 /// `#[path = "..."]`. `dto_bindgen_core` derives a descriptor's Rust module
    151 /// path from its source filename, so these routed files need the same logical
    152 /// path that rustc assigns through their public facade. The physical source
    153 /// path remains the authority used for reading and diagnostics.
    154 fn canonical_dto_source_path(source_file: &str) -> &str {
    155     match source_file {
    156         "crates/event/src/account.rs" => "crates/event/src/profile/account.rs",
    157         "crates/event/src/app_data.rs" => "crates/event/src/social/app_data.rs",
    158         "crates/event/src/comment.rs" => "crates/event/src/post/comment.rs",
    159         "crates/event/src/coop.rs" => "crates/event/src/farm/coop.rs",
    160         "crates/event/src/document.rs" => "crates/event/src/post/document.rs",
    161         "crates/event/src/follow.rs" => "crates/event/src/social/follow.rs",
    162         "crates/event/src/gcs.rs" => "crates/event/src/farm/change_set.rs",
    163         "crates/event/src/geochat.rs" => "crates/event/src/social/geochat.rs",
    164         "crates/event/src/gift_wrap.rs" => "crates/event/src/social/gift_wrap.rs",
    165         "crates/event/src/job.rs" => "crates/event/src/social/job.rs",
    166         "crates/event/src/job_feedback.rs" => "crates/event/src/social/job_feedback.rs",
    167         "crates/event/src/job_request.rs" => "crates/event/src/social/job_request.rs",
    168         "crates/event/src/job_result.rs" => "crates/event/src/social/job_result.rs",
    169         "crates/event/src/list.rs" => "crates/event/src/social/list.rs",
    170         "crates/event/src/list_set.rs" => "crates/event/src/social/list_set.rs",
    171         "crates/event/src/message.rs" => "crates/event/src/social/message.rs",
    172         "crates/event/src/message_file.rs" => "crates/event/src/social/message_file.rs",
    173         "crates/event/src/operational_listing.rs" => "crates/event/src/listing/operational.rs",
    174         "crates/event/src/order.rs" => "crates/event/src/trade/order.rs",
    175         "crates/event/src/order_economics.rs" => "crates/event/src/trade/order_economics.rs",
    176         "crates/event/src/plot.rs" => "crates/event/src/farm/plot.rs",
    177         "crates/event/src/reaction.rs" => "crates/event/src/post/reaction.rs",
    178         "crates/event/src/relay_document.rs" => "crates/event/src/social/relay_document.rs",
    179         "crates/event/src/resource_area.rs" => "crates/event/src/farm/resource_area.rs",
    180         "crates/event/src/resource_cap.rs" => "crates/event/src/farm/resource_cap.rs",
    181         "crates/event/src/seal.rs" => "crates/event/src/social/seal.rs",
    182         "crates/event/src/trade_validation.rs" => "crates/event/src/trade/validation.rs",
    183         _ => source_file,
    184     }
    185 }
    186 
    187 fn workspace_path(workspace_root: &Path, configured_path: &str) -> Result<PathBuf, String> {
    188     let relative = Path::new(configured_path);
    189     if configured_path.is_empty()
    190         || configured_path.contains('\\')
    191         || configured_path
    192             .split('/')
    193             .any(|segment| matches!(segment, "" | "." | ".."))
    194         || has_windows_drive_prefix(configured_path)
    195         || relative.is_absolute()
    196         || !relative
    197             .components()
    198             .all(|component| matches!(component, Component::Normal(_)))
    199     {
    200         return Err(format!(
    201             "DTO root authority path must be a normalized workspace-relative path: `{configured_path}`"
    202         ));
    203     }
    204     Ok(workspace_root.join(relative))
    205 }
    206 
    207 fn has_windows_drive_prefix(path: &str) -> bool {
    208     let bytes = path.as_bytes();
    209     bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
    210 }
    211 
    212 fn validate_no_symlink_components(
    213     workspace_root: &Path,
    214     configured_path: &str,
    215     allow_missing_final: bool,
    216     role: &str,
    217 ) -> Result<(), String> {
    218     let relative = Path::new(configured_path);
    219     let component_count = relative.components().count();
    220     let mut current = workspace_root.to_path_buf();
    221     for (index, component) in relative.components().enumerate() {
    222         let Component::Normal(segment) = component else {
    223             return Err(format!(
    224                 "{role} path is not normalized: `{configured_path}`"
    225             ));
    226         };
    227         current.push(segment);
    228         match current.symlink_metadata() {
    229             Ok(metadata) if metadata.file_type().is_symlink() => {
    230                 return Err(format!(
    231                     "{role} path contains a symlink component: `{}`",
    232                     current.display()
    233                 ));
    234             }
    235             Ok(_) => {}
    236             Err(error)
    237                 if error.kind() == ErrorKind::NotFound
    238                     && allow_missing_final
    239                     && index + 1 == component_count =>
    240             {
    241                 return Ok(());
    242             }
    243             Err(error) => {
    244                 return Err(format!(
    245                     "failed to inspect {role} path component `{}`: {error}",
    246                     current.display()
    247                 ));
    248             }
    249         }
    250     }
    251     Ok(())
    252 }
    253 
    254 fn validate_existing_regular_file(
    255     workspace_root: &Path,
    256     configured_path: &str,
    257     role: &str,
    258 ) -> Result<(), String> {
    259     validate_no_symlink_components(workspace_root, configured_path, false, role)?;
    260     let path = workspace_path(workspace_root, configured_path)?;
    261     let metadata = path
    262         .metadata()
    263         .map_err(|error| format!("failed to inspect {role} `{configured_path}`: {error}"))?;
    264     if !metadata.is_file() {
    265         return Err(format!(
    266             "{role} must be a regular file: `{configured_path}`"
    267         ));
    268     }
    269     Ok(())
    270 }
    271 
    272 fn check_generated_roots(generated: &[GeneratedRootFile]) -> Result<(), String> {
    273     let mut stale = Vec::new();
    274     for output in generated {
    275         match fs::read(&output.path) {
    276             Ok(current) if current == output.contents.as_bytes() => {}
    277             Ok(_) => stale.push(format!("stale `{}`", output.display_path)),
    278             Err(error) if error.kind() == ErrorKind::NotFound => {
    279                 stale.push(format!("missing `{}`", output.display_path));
    280             }
    281             Err(error) => {
    282                 return Err(format!(
    283                     "failed to read generated DTO roots `{}`: {error}",
    284                     output.display_path
    285                 ));
    286             }
    287         }
    288     }
    289 
    290     if stale.is_empty() {
    291         Ok(())
    292     } else {
    293         Err(format!(
    294             "generated DTO roots are not fresh:\n- {}\nrun `cargo xtask dto-roots --write`",
    295             stale.join("\n- ")
    296         ))
    297     }
    298 }
    299 
    300 fn validate_output_paths(
    301     workspace_root: &Path,
    302     generated: &[GeneratedRootFile],
    303 ) -> Result<(), String> {
    304     let canonical_root = workspace_root.canonicalize().map_err(|error| {
    305         format!(
    306             "failed to resolve DTO workspace root `{}`: {error}",
    307             workspace_root.display()
    308         )
    309     })?;
    310     for output in generated {
    311         validate_no_symlink_components(
    312             workspace_root,
    313             &output.display_path,
    314             true,
    315             "generated DTO root",
    316         )?;
    317         let parent = output.path.parent().ok_or_else(|| {
    318             format!(
    319                 "generated DTO root path has no parent: `{}`",
    320                 output.display_path
    321             )
    322         })?;
    323         if !parent.is_dir() {
    324             return Err(format!(
    325                 "generated DTO root parent does not exist: `{}`",
    326                 parent.display()
    327             ));
    328         }
    329         let canonical_parent = parent.canonicalize().map_err(|error| {
    330             format!(
    331                 "failed to resolve generated DTO root parent `{}`: {error}",
    332                 parent.display()
    333             )
    334         })?;
    335         if !canonical_parent.starts_with(&canonical_root) {
    336             return Err(format!(
    337                 "generated DTO root parent escapes the workspace: `{}`",
    338                 parent.display()
    339             ));
    340         }
    341         let metadata = match output.path.symlink_metadata() {
    342             Ok(metadata) => Some(metadata),
    343             Err(error) if error.kind() == ErrorKind::NotFound => None,
    344             Err(error) => {
    345                 return Err(format!(
    346                     "failed to inspect generated DTO roots `{}`: {error}",
    347                     output.display_path
    348                 ));
    349             }
    350         };
    351         if metadata
    352             .as_ref()
    353             .is_some_and(|metadata| metadata.file_type().is_symlink())
    354         {
    355             return Err(format!(
    356                 "generated DTO root cannot be a symlink: `{}`",
    357                 output.display_path
    358             ));
    359         }
    360         if metadata
    361             .as_ref()
    362             .is_some_and(|metadata| !metadata.is_file())
    363         {
    364             return Err(format!(
    365                 "generated DTO root is not a regular file: `{}`",
    366                 output.display_path
    367             ));
    368         }
    369     }
    370     Ok(())
    371 }
    372 
    373 fn write_generated_roots(generated: &[GeneratedRootFile]) -> Result<(), String> {
    374     write_generated_roots_with(generated, persist_temporary_file)
    375 }
    376 
    377 fn write_generated_roots_with<F>(
    378     generated: &[GeneratedRootFile],
    379     mut persist: F,
    380 ) -> Result<(), String>
    381 where
    382     F: FnMut(NamedTempFile, &Path) -> Result<(), PersistFailure>,
    383 {
    384     let staged = stage_generated_roots(generated)?;
    385     let mut committed = Vec::with_capacity(staged.len());
    386     for output in staged {
    387         let original = OriginalRootFile {
    388             path: output.path.clone(),
    389             display_path: output.display_path.clone(),
    390             permissions: output
    391                 .original
    392                 .as_ref()
    393                 .map(|original| original.permissions.clone()),
    394             contents: output.original.map(|original| original.contents),
    395         };
    396         if let Err((_temporary, error)) = persist(output.temporary, &output.path) {
    397             let rollback = rollback_original_roots(&committed);
    398             return Err(write_failure_with_rollback(
    399                 &output.display_path,
    400                 &error,
    401                 rollback,
    402             ));
    403         }
    404         committed.push(original);
    405     }
    406 
    407     if let Err(error) = check_generated_roots(generated) {
    408         let rollback = rollback_original_roots(&committed);
    409         return Err(match rollback {
    410             Ok(()) => format!("{error}; restored all generated DTO roots"),
    411             Err(rollback_error) => format!("{error}; rollback also failed: {rollback_error}"),
    412         });
    413     }
    414     Ok(())
    415 }
    416 
    417 fn stage_generated_roots(generated: &[GeneratedRootFile]) -> Result<Vec<StagedRootFile>, String> {
    418     let mut staged = Vec::new();
    419     for output in generated {
    420         let original = match fs::read(&output.path) {
    421             Ok(current) if current == output.contents.as_bytes() => continue,
    422             Ok(current) => {
    423                 let permissions = fs::metadata(&output.path)
    424                     .map_err(|error| {
    425                         format!(
    426                             "failed to inspect generated DTO roots `{}` before staging: {error}",
    427                             output.display_path
    428                         )
    429                     })?
    430                     .permissions();
    431                 Some(OriginalFileState {
    432                     contents: current,
    433                     permissions,
    434                 })
    435             }
    436             Err(error) if error.kind() == ErrorKind::NotFound => None,
    437             Err(error) => {
    438                 return Err(format!(
    439                     "failed to read generated DTO roots `{}` before staging: {error}",
    440                     output.display_path
    441                 ));
    442             }
    443         };
    444         let parent = output.path.parent().ok_or_else(|| {
    445             format!(
    446                 "generated DTO root path has no parent: `{}`",
    447                 output.display_path
    448             )
    449         })?;
    450         let desired_permissions = original
    451             .as_ref()
    452             .map(|original| original.permissions.clone())
    453             .or_else(default_generated_file_permissions);
    454         let temporary = stage_bytes(
    455             parent,
    456             output.contents.as_bytes(),
    457             desired_permissions.as_ref(),
    458         )
    459         .map_err(|error| {
    460             format!(
    461                 "failed to stage generated DTO roots `{}`: {error}",
    462                 output.display_path
    463             )
    464         })?;
    465         staged.push(StagedRootFile {
    466             path: output.path.clone(),
    467             display_path: output.display_path.clone(),
    468             temporary,
    469             original,
    470         });
    471     }
    472     Ok(staged)
    473 }
    474 
    475 fn stage_bytes(
    476     parent: &Path,
    477     contents: &[u8],
    478     permissions: Option<&fs::Permissions>,
    479 ) -> io::Result<NamedTempFile> {
    480     let mut temporary = NamedTempFile::new_in(parent)?;
    481     temporary.write_all(contents)?;
    482     temporary.flush()?;
    483     if let Some(permissions) = permissions {
    484         fs::set_permissions(temporary.path(), permissions.clone())?;
    485     }
    486     temporary.as_file().sync_all()?;
    487     let staged = fs::read(temporary.path())?;
    488     if staged != contents {
    489         return Err(io::Error::other("staged DTO root bytes do not match input"));
    490     }
    491     Ok(temporary)
    492 }
    493 
    494 #[cfg(unix)]
    495 fn default_generated_file_permissions() -> Option<fs::Permissions> {
    496     use std::os::unix::fs::PermissionsExt;
    497 
    498     Some(fs::Permissions::from_mode(0o644))
    499 }
    500 
    501 #[cfg(not(unix))]
    502 fn default_generated_file_permissions() -> Option<fs::Permissions> {
    503     None
    504 }
    505 
    506 fn persist_temporary_file(
    507     temporary: NamedTempFile,
    508     destination: &Path,
    509 ) -> Result<(), PersistFailure> {
    510     temporary
    511         .persist(destination)
    512         .map(|_| ())
    513         .map_err(|error| (error.file, error.error))
    514 }
    515 
    516 fn rollback_original_roots(committed: &[OriginalRootFile]) -> Result<(), String> {
    517     let mut failures = Vec::new();
    518     for original in committed.iter().rev() {
    519         let result = if let Some(contents) = &original.contents {
    520             let parent = original.path.parent().ok_or_else(|| {
    521                 io::Error::other(format!(
    522                     "generated DTO root path has no parent: `{}`",
    523                     original.display_path
    524                 ))
    525             });
    526             parent
    527                 .and_then(|parent| stage_bytes(parent, contents, original.permissions.as_ref()))
    528                 .and_then(|temporary| {
    529                     persist_temporary_file(temporary, &original.path)
    530                         .map_err(|(_temporary, error)| error)
    531                 })
    532         } else {
    533             match fs::remove_file(&original.path) {
    534                 Ok(()) => Ok(()),
    535                 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
    536                 Err(error) => Err(error),
    537             }
    538         };
    539         if let Err(error) = result {
    540             failures.push(format!("`{}`: {error}", original.display_path));
    541         }
    542     }
    543     if failures.is_empty() {
    544         Ok(())
    545     } else {
    546         Err(failures.join("; "))
    547     }
    548 }
    549 
    550 fn write_failure_with_rollback(
    551     display_path: &str,
    552     error: &io::Error,
    553     rollback: Result<(), String>,
    554 ) -> String {
    555     match rollback {
    556         Ok(()) => format!(
    557             "failed to commit generated DTO roots `{display_path}`: {error}; restored all previously committed outputs"
    558         ),
    559         Err(rollback_error) => format!(
    560             "failed to commit generated DTO roots `{display_path}`: {error}; rollback also failed: {rollback_error}"
    561         ),
    562     }
    563 }
    564 
    565 #[cfg(test)]
    566 mod tests {
    567     use super::*;
    568     use std::cell::Cell;
    569     use tempfile::TempDir;
    570 
    571     fn fixture_workspace() -> TempDir {
    572         let workspace = TempDir::new().expect("create fixture workspace");
    573         let root = workspace.path();
    574         fs::create_dir_all(root.join("crates/demo/src/generated"))
    575             .expect("create fixture workspace");
    576         fs::write(
    577             root.join("dto_bindgen.toml"),
    578             r#"schema_version = 1
    579 
    580 [[package]]
    581 key = "demo"
    582 rust_package = "demo"
    583 rust_crate = "demo"
    584 npm = "@radroots/demo"
    585 out_dir = "target/demo"
    586 
    587 [package.root_discovery]
    588 mode = "source_manifest"
    589 source_files = ["crates/demo/src/model.rs"]
    590 root_module_file = "crates/demo/src/generated/dto_roots.rs"
    591 "#,
    592         )
    593         .expect("write fixture config");
    594         fs::write(
    595             root.join("crates/demo/src/model.rs"),
    596             r#"#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    597 #[cfg_attr(feature = "dto-bindgen", dto(export))]
    598 pub struct DemoDto {
    599     pub value: String,
    600 }
    601 "#,
    602         )
    603         .expect("write fixture source");
    604         workspace
    605     }
    606 
    607     fn add_second_package(root: &Path) {
    608         fs::create_dir_all(root.join("crates/second/src/generated"))
    609             .expect("create second package");
    610         let config_path = root.join("dto_bindgen.toml");
    611         let mut config = fs::read_to_string(&config_path).expect("read fixture config");
    612         config.push_str(
    613             r#"
    614 [[package]]
    615 key = "second"
    616 rust_package = "second"
    617 rust_crate = "second"
    618 npm = "@radroots/second"
    619 out_dir = "target/second"
    620 
    621 [package.root_discovery]
    622 mode = "source_manifest"
    623 source_files = ["crates/second/src/model.rs"]
    624 root_module_file = "crates/second/src/generated/dto_roots.rs"
    625 "#,
    626         );
    627         fs::write(config_path, config).expect("write two-package config");
    628         fs::write(
    629             root.join("crates/second/src/model.rs"),
    630             r#"#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
    631 #[cfg_attr(feature = "dto-bindgen", dto(export))]
    632 pub struct SecondDto {
    633     pub value: String,
    634 }
    635 "#,
    636         )
    637         .expect("write second fixture source");
    638     }
    639 
    640     #[test]
    641     fn write_then_check_is_deterministic() {
    642         let workspace = fixture_workspace();
    643         let root = workspace.path();
    644         run(&["--write".to_owned()], root).expect("write roots");
    645         let output = root.join("crates/demo/src/generated/dto_roots.rs");
    646         let first = fs::read_to_string(&output).expect("read generated roots");
    647         assert!(first.contains("crate::model::DemoDto"));
    648 
    649         run(&["--write".to_owned()], root).expect("repeat write roots");
    650         assert_eq!(
    651             fs::read_to_string(&output).expect("read repeated roots"),
    652             first
    653         );
    654         check(root).expect("fresh roots");
    655     }
    656 
    657     #[test]
    658     fn check_aggregates_two_package_drift_without_mutation() {
    659         let workspace = fixture_workspace();
    660         let root = workspace.path();
    661         add_second_package(root);
    662         run(&["--write".to_owned()], root).expect("write roots");
    663         let demo = root.join("crates/demo/src/generated/dto_roots.rs");
    664         let second = root.join("crates/second/src/generated/dto_roots.rs");
    665         fs::write(&demo, "stale\n").expect("write drift");
    666         fs::remove_file(&second).expect("remove generated roots");
    667 
    668         let stale = check(root).expect_err("reject stale roots");
    669         assert!(stale.contains("stale `crates/demo/src/generated/dto_roots.rs`"));
    670         assert!(stale.contains("missing `crates/second/src/generated/dto_roots.rs`"));
    671         assert!(stale.contains("cargo xtask dto-roots --write"));
    672         assert_eq!(fs::read_to_string(&demo).expect("read drift"), "stale\n");
    673         assert!(!second.exists());
    674 
    675         run(&["--write".to_owned()], root).expect("repair both roots");
    676         check(root).expect("both roots fresh");
    677         assert!(
    678             fs::read_to_string(demo)
    679                 .expect("read demo roots")
    680                 .contains("DemoDto")
    681         );
    682         assert!(
    683             fs::read_to_string(second)
    684                 .expect("read second roots")
    685                 .contains("SecondDto")
    686         );
    687     }
    688 
    689     #[test]
    690     fn rejects_invalid_modes_paths_and_missing_authority() {
    691         let workspace = fixture_workspace();
    692         let root = workspace.path();
    693         for args in [
    694             Vec::<String>::new(),
    695             vec!["--unknown".to_owned()],
    696             vec!["--check".to_owned(), "extra".to_owned()],
    697             vec!["--check".to_owned(), "--write".to_owned()],
    698         ] {
    699             let mode = run(&args, root).expect_err("require one explicit valid mode");
    700             assert!(mode.contains("--check|--write"));
    701         }
    702 
    703         for invalid in ["", "/tmp/output.rs", "../output.rs", "a\\b.rs", "a/./b.rs"] {
    704             assert!(workspace_path(root, invalid).is_err(), "accepted {invalid}");
    705         }
    706 
    707         fs::remove_file(root.join("dto_bindgen.toml")).expect("remove authority");
    708         let missing = check(root).expect_err("reject missing authority");
    709         assert!(missing.contains("failed to inspect DTO root authority path component"));
    710     }
    711 
    712     #[test]
    713     fn supports_top_level_source_manifest_authority() {
    714         let workspace = fixture_workspace();
    715         let root = workspace.path();
    716         fs::write(
    717             root.join("dto_bindgen.toml"),
    718             r#"schema_version = 1
    719 
    720 [root_discovery]
    721 mode = "source_manifest"
    722 source_files = ["crates/demo/src/model.rs"]
    723 root_module_file = "crates/demo/src/generated/dto_roots.rs"
    724 "#,
    725         )
    726         .expect("write top-level root authority");
    727 
    728         run(&["--write".to_owned()], root).expect("write top-level roots");
    729         check(root).expect("top-level roots fresh");
    730         assert!(
    731             fs::read_to_string(root.join("crates/demo/src/generated/dto_roots.rs"))
    732                 .expect("read top-level roots")
    733                 .contains("DemoDto")
    734         );
    735     }
    736 
    737     #[test]
    738     fn event_routed_sources_generate_crate_visible_module_paths() {
    739         for (physical, routed) in [
    740             ("account.rs", "profile/account.rs"),
    741             ("app_data.rs", "social/app_data.rs"),
    742             ("comment.rs", "post/comment.rs"),
    743             ("coop.rs", "farm/coop.rs"),
    744             ("document.rs", "post/document.rs"),
    745             ("follow.rs", "social/follow.rs"),
    746             ("gcs.rs", "farm/change_set.rs"),
    747             ("geochat.rs", "social/geochat.rs"),
    748             ("gift_wrap.rs", "social/gift_wrap.rs"),
    749             ("job.rs", "social/job.rs"),
    750             ("job_feedback.rs", "social/job_feedback.rs"),
    751             ("job_request.rs", "social/job_request.rs"),
    752             ("job_result.rs", "social/job_result.rs"),
    753             ("list.rs", "social/list.rs"),
    754             ("list_set.rs", "social/list_set.rs"),
    755             ("message.rs", "social/message.rs"),
    756             ("message_file.rs", "social/message_file.rs"),
    757             ("operational_listing.rs", "listing/operational.rs"),
    758             ("order.rs", "trade/order.rs"),
    759             ("order_economics.rs", "trade/order_economics.rs"),
    760             ("plot.rs", "farm/plot.rs"),
    761             ("reaction.rs", "post/reaction.rs"),
    762             ("relay_document.rs", "social/relay_document.rs"),
    763             ("resource_area.rs", "farm/resource_area.rs"),
    764             ("resource_cap.rs", "farm/resource_cap.rs"),
    765             ("seal.rs", "social/seal.rs"),
    766             ("trade_validation.rs", "trade/validation.rs"),
    767         ] {
    768             let physical = format!("crates/event/src/{physical}");
    769             let routed = format!("crates/event/src/{routed}");
    770             assert_eq!(canonical_dto_source_path(&physical), routed);
    771         }
    772 
    773         assert_eq!(
    774             canonical_dto_source_path("crates/event/src/farm.rs"),
    775             "crates/event/src/farm.rs"
    776         );
    777         assert_eq!(
    778             canonical_dto_source_path("crates/core/src/money.rs"),
    779             "crates/core/src/money.rs"
    780         );
    781     }
    782 
    783     #[test]
    784     fn persist_failure_rolls_back_every_committed_output() {
    785         let workspace = fixture_workspace();
    786         let root = workspace.path();
    787         add_second_package(root);
    788         let config = Config::from_toml_path(root.join("dto_bindgen.toml"))
    789             .expect("load two-package authority");
    790         let generated = generate_configured_roots(root, &config).expect("generate roots");
    791         validate_output_paths(root, &generated).expect("validate root paths");
    792         let demo = root.join("crates/demo/src/generated/dto_roots.rs");
    793         let second = root.join("crates/second/src/generated/dto_roots.rs");
    794         fs::write(&demo, "original demo\n").expect("write original demo");
    795         fs::write(&second, "original second\n").expect("write original second");
    796         #[cfg(unix)]
    797         {
    798             use std::os::unix::fs::PermissionsExt;
    799 
    800             fs::set_permissions(&demo, fs::Permissions::from_mode(0o640))
    801                 .expect("set demo permissions");
    802             fs::set_permissions(&second, fs::Permissions::from_mode(0o604))
    803                 .expect("set second permissions");
    804         }
    805 
    806         let persist_count = Cell::new(0_u8);
    807         let error = write_generated_roots_with(&generated, |temporary, destination| {
    808             persist_count.set(persist_count.get() + 1);
    809             if persist_count.get() == 2 {
    810                 Err((temporary, io::Error::other("injected persist failure")))
    811             } else {
    812                 persist_temporary_file(temporary, destination)
    813             }
    814         })
    815         .expect_err("second persist must fail");
    816 
    817         assert!(error.contains("injected persist failure"));
    818         assert!(error.contains("restored all previously committed outputs"));
    819         assert_eq!(
    820             fs::read_to_string(&demo).expect("read demo"),
    821             "original demo\n"
    822         );
    823         assert_eq!(
    824             fs::read_to_string(&second).expect("read second"),
    825             "original second\n"
    826         );
    827         #[cfg(unix)]
    828         {
    829             assert_eq!(unix_mode(&demo), 0o640);
    830             assert_eq!(unix_mode(&second), 0o604);
    831         }
    832     }
    833 
    834     #[test]
    835     fn persist_failure_removes_a_newly_committed_output() {
    836         let workspace = fixture_workspace();
    837         let root = workspace.path();
    838         add_second_package(root);
    839         let config = Config::from_toml_path(root.join("dto_bindgen.toml"))
    840             .expect("load two-package authority");
    841         let generated = generate_configured_roots(root, &config).expect("generate roots");
    842         validate_output_paths(root, &generated).expect("validate root paths");
    843         let demo = root.join("crates/demo/src/generated/dto_roots.rs");
    844         let second = root.join("crates/second/src/generated/dto_roots.rs");
    845         fs::write(&second, "original second\n").expect("write original second");
    846 
    847         let persist_count = Cell::new(0_u8);
    848         write_generated_roots_with(&generated, |temporary, destination| {
    849             persist_count.set(persist_count.get() + 1);
    850             if persist_count.get() == 2 {
    851                 Err((temporary, io::Error::other("injected persist failure")))
    852             } else {
    853                 persist_temporary_file(temporary, destination)
    854             }
    855         })
    856         .expect_err("second persist must fail");
    857 
    858         assert!(!demo.exists());
    859         assert_eq!(
    860             fs::read_to_string(second).expect("read second"),
    861             "original second\n"
    862         );
    863     }
    864 
    865     #[cfg(unix)]
    866     fn unix_mode(path: &Path) -> u32 {
    867         use std::os::unix::fs::PermissionsExt;
    868 
    869         fs::metadata(path)
    870             .expect("read permissions")
    871             .permissions()
    872             .mode()
    873             & 0o777
    874     }
    875 
    876     #[cfg(unix)]
    877     #[test]
    878     fn write_creates_and_preserves_governed_source_permissions() {
    879         use std::os::unix::fs::PermissionsExt;
    880 
    881         let workspace = fixture_workspace();
    882         let root = workspace.path();
    883         let output = root.join("crates/demo/src/generated/dto_roots.rs");
    884         run(&["--write".to_owned()], root).expect("create generated roots");
    885         assert_eq!(unix_mode(&output), 0o644);
    886 
    887         fs::write(&output, "stale\n").expect("make roots stale");
    888         fs::set_permissions(&output, fs::Permissions::from_mode(0o640))
    889             .expect("set custom permissions");
    890         run(&["--write".to_owned()], root).expect("replace generated roots");
    891         assert_eq!(unix_mode(&output), 0o640);
    892     }
    893 
    894     #[cfg(unix)]
    895     #[test]
    896     fn check_rejects_generated_output_symlinks() {
    897         use std::os::unix::fs::symlink;
    898 
    899         let workspace = fixture_workspace();
    900         let root = workspace.path();
    901         let output = root.join("crates/demo/src/generated/dto_roots.rs");
    902         let target = root.join("target.rs");
    903         fs::write(&target, "outside authority\n").expect("write symlink target");
    904         symlink(&target, &output).expect("create generated root symlink");
    905 
    906         let error = check(root).expect_err("reject generated root symlink");
    907         assert!(error.contains("generated DTO root path contains a symlink component"));
    908     }
    909 
    910     #[cfg(unix)]
    911     #[test]
    912     fn check_rejects_source_and_output_parent_symlinks() {
    913         use std::os::unix::fs::symlink;
    914 
    915         let source_workspace = fixture_workspace();
    916         let source_root = source_workspace.path();
    917         let source = source_root.join("crates/demo/src/model.rs");
    918         let outside_workspace = TempDir::new().expect("create outside workspace");
    919         let outside_source = outside_workspace.path().join("outside_source.rs");
    920         fs::write(
    921             &outside_source,
    922             fs::read_to_string(&source).expect("read source"),
    923         )
    924         .expect("write outside source");
    925         fs::remove_file(&source).expect("remove source");
    926         symlink(&outside_source, &source).expect("create source symlink");
    927 
    928         let source_error = check(source_root).expect_err("reject source symlink");
    929         assert!(source_error.contains("DTO source path contains a symlink component"));
    930 
    931         let output_workspace = fixture_workspace();
    932         let output_root = output_workspace.path();
    933         let output_parent = output_root.join("crates/demo/src/generated");
    934         let outside_parent_workspace = TempDir::new().expect("create outside output workspace");
    935         let outside_parent = outside_parent_workspace.path().join("generated");
    936         fs::create_dir_all(&outside_parent).expect("create outside output parent");
    937         fs::remove_dir(&output_parent).expect("remove output parent");
    938         symlink(&outside_parent, &output_parent).expect("create output parent symlink");
    939 
    940         let output_error = check(output_root).expect_err("reject output parent symlink");
    941         assert!(output_error.contains("generated DTO root path contains a symlink component"));
    942     }
    943 
    944     #[test]
    945     fn authority_and_sources_must_be_regular_files() {
    946         let source_workspace = fixture_workspace();
    947         let source_root = source_workspace.path();
    948         let source = source_root.join("crates/demo/src/model.rs");
    949         fs::remove_file(&source).expect("remove source file");
    950         fs::create_dir(&source).expect("create source directory");
    951         let source_error = check(source_root).expect_err("reject source directory");
    952         assert!(source_error.contains("DTO source must be a regular file"));
    953 
    954         let authority_workspace = fixture_workspace();
    955         let authority_root = authority_workspace.path();
    956         let authority = authority_root.join("dto_bindgen.toml");
    957         fs::remove_file(&authority).expect("remove authority file");
    958         fs::create_dir(&authority).expect("create authority directory");
    959         let authority_error = check(authority_root).expect_err("reject authority directory");
    960         assert!(authority_error.contains("DTO root authority must be a regular file"));
    961     }
    962 }