Skip to main content

serializer/llm/
serializer_output.rs

1//! Serializer Output Module for DX Serializer
2//!
3//! Generates LLM and Machine format files from .sr/.dx source files.
4//! Output files are stored in `.dx/serializer/` with proper naming.
5//!
6//! ## Output Structure
7//!
8//! ```text
9//! .dx/serializer/
10//! ├── javascript-lint.llm      # LLM-optimized format
11//! └── javascript-lint.machine  # Binary format (used at runtime)
12//! ```
13//!
14//! ## Format Flow (2026 Architecture)
15//!
16//! 1. Source (.sr/.dx) - LLM format stored on disk
17//! 2. LLM (.llm) - Optional normalized copy for workflows that need it
18//! 3. Machine (.machine) - Binary for fast runtime loading
19
20#[cfg(feature = "converters")]
21use crate::converters::{json_to_document, toml_to_document};
22use crate::llm::convert::{
23    CompressionAlgorithm, ConvertError, document_to_llm, llm_to_document,
24    try_document_to_machine_with_compression,
25};
26use crate::llm::types::DxDocument;
27use std::fs;
28use std::io::{self, Write};
29use std::path::{Component, Path, PathBuf};
30use std::time::UNIX_EPOCH;
31use thiserror::Error;
32
33/// Serializer output errors
34#[derive(Debug, Error)]
35pub enum SerializerOutputError {
36    /// Filesystem read or write failed.
37    #[error("IO error: {0}")]
38    Io(#[from] io::Error),
39
40    /// Human or LLM format parsing failed.
41    #[error("Parse error: {0}")]
42    Parse(String),
43
44    /// Conversion between serializer formats failed.
45    #[error("Conversion error: {0}")]
46    Convert(#[from] ConvertError),
47
48    /// Source or output path was not valid for serializer output.
49    #[error("Invalid path: {0}")]
50    InvalidPath(String),
51
52    /// Machine metadata did not match the current source or machine bytes.
53    #[error("Invalid metadata: {0}")]
54    InvalidMetadata(String),
55
56    /// Output directory could not be created.
57    #[error("Directory creation failed: {0}")]
58    DirectoryCreation(String),
59}
60
61/// Configuration for serializer output
62#[derive(Debug, Clone)]
63pub struct SerializerOutputConfig {
64    /// Root directory for output files (default: .dx/serializer)
65    pub output_dir: PathBuf,
66    /// Generate LLM format files
67    pub generate_llm: bool,
68    /// Generate machine format files
69    pub generate_machine: bool,
70    /// Compression algorithm for machine format
71    pub compression: CompressionAlgorithm,
72    /// Generate source/machine validation metadata for cache readers
73    pub generate_metadata: bool,
74}
75
76impl Default for SerializerOutputConfig {
77    fn default() -> Self {
78        Self {
79            output_dir: PathBuf::from(".dx/serializer"),
80            generate_llm: true,
81            generate_machine: true,
82            compression: CompressionAlgorithm::default(),
83            generate_metadata: false,
84        }
85    }
86}
87
88impl SerializerOutputConfig {
89    /// Create a new config with default settings
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Set the output directory
95    pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
96        self.output_dir = dir.into();
97        self
98    }
99
100    /// Set whether to generate LLM format
101    pub fn with_llm(mut self, generate: bool) -> Self {
102        self.generate_llm = generate;
103        self
104    }
105
106    /// Set whether to generate machine format
107    pub fn with_machine(mut self, generate: bool) -> Self {
108        self.generate_machine = generate;
109        self
110    }
111
112    /// Set compression algorithm
113    pub fn with_compression(mut self, compression: CompressionAlgorithm) -> Self {
114        self.compression = compression;
115        self
116    }
117
118    /// Set whether to generate source/machine validation metadata.
119    pub fn with_metadata(mut self, generate: bool) -> Self {
120        self.generate_metadata = generate;
121        self
122    }
123}
124
125/// Output paths for a serialized file
126#[derive(Debug, Clone)]
127pub struct SerializerPaths {
128    /// Original source path (.sr/.dx file)
129    pub source: PathBuf,
130    /// LLM format output path (.llm)
131    pub llm: PathBuf,
132    /// Machine format output path (.machine)
133    pub machine: PathBuf,
134    /// Machine metadata output path (.machine.meta.json)
135    pub metadata: PathBuf,
136}
137
138/// Result of serializer output generation
139#[derive(Debug)]
140pub struct SerializerResult {
141    /// Output paths
142    pub paths: SerializerPaths,
143    /// Whether LLM format was generated
144    pub llm_generated: bool,
145    /// Whether machine format was generated
146    pub machine_generated: bool,
147    /// Size of LLM output in bytes
148    pub llm_size: usize,
149    /// Size of machine output in bytes
150    pub machine_size: usize,
151}
152
153/// Serializer output generator
154pub struct SerializerOutput {
155    config: SerializerOutputConfig,
156}
157
158impl SerializerOutput {
159    /// Create a new serializer output with default config
160    pub fn new() -> Self {
161        Self {
162            config: SerializerOutputConfig::default(),
163        }
164    }
165
166    /// Create with custom config
167    pub fn with_config(config: SerializerOutputConfig) -> Self {
168        Self { config }
169    }
170
171    /// Get output paths for a source file
172    pub fn get_paths(&self, source_path: &Path) -> SerializerPaths {
173        let stem = flatten_serializer_output_stem(source_path);
174
175        SerializerPaths {
176            source: source_path.to_path_buf(),
177            llm: self.config.output_dir.join(format!("{}.llm", stem)),
178            machine: self.config.output_dir.join(format!("{}.machine", stem)),
179            metadata: self
180                .config
181                .output_dir
182                .join(format!("{}.machine.meta.json", stem)),
183        }
184    }
185
186    /// Process a `.sr`, `.dx`, extensionless `dx`, JSON, or TOML source file and generate outputs.
187    pub fn process_file(
188        &self,
189        source_path: &Path,
190    ) -> Result<SerializerResult, SerializerOutputError> {
191        let content = fs::read_to_string(source_path)?;
192        let doc = parse_source_document(source_path, &content)?;
193
194        self.process_document_with_source(&doc, source_path, Some(content.as_bytes()))
195    }
196
197    /// Process a DxDocument and generate outputs
198    pub fn process_document(
199        &self,
200        doc: &DxDocument,
201        source_path: &Path,
202    ) -> Result<SerializerResult, SerializerOutputError> {
203        self.process_document_with_source(doc, source_path, None)
204    }
205
206    fn process_document_with_source(
207        &self,
208        doc: &DxDocument,
209        source_path: &Path,
210        source_bytes: Option<&[u8]>,
211    ) -> Result<SerializerResult, SerializerOutputError> {
212        let paths = self.get_paths(source_path);
213
214        // Ensure output directory exists
215        fs::create_dir_all(&self.config.output_dir).map_err(|e| {
216            SerializerOutputError::DirectoryCreation(format!(
217                "{}: {}",
218                self.config.output_dir.display(),
219                e
220            ))
221        })?;
222
223        let mut result = SerializerResult {
224            paths: paths.clone(),
225            llm_generated: false,
226            machine_generated: false,
227            llm_size: 0,
228            machine_size: 0,
229        };
230
231        // Generate LLM format (compact, token-efficient)
232        if self.config.generate_llm {
233            let llm_content = document_to_llm(doc);
234            fs::write(&paths.llm, &llm_content)?;
235            result.llm_generated = true;
236            result.llm_size = llm_content.len();
237        }
238
239        // Generate machine format
240        if self.config.generate_machine {
241            let machine_content =
242                try_document_to_machine_with_compression(doc, self.config.compression)?;
243            write_atomic(&paths.machine, machine_content.as_bytes())?;
244            result.machine_generated = true;
245            result.machine_size = machine_content.data.len();
246
247            if self.config.generate_metadata {
248                if let Some(source_bytes) = source_bytes {
249                    let metadata = machine_metadata_json(
250                        source_path,
251                        source_bytes,
252                        &paths.machine,
253                        &machine_content.data,
254                    );
255                    write_atomic(&paths.metadata, metadata.as_bytes())?;
256                }
257            }
258        }
259
260        Ok(result)
261    }
262
263    /// Process all serializer-supported files in a directory.
264    pub fn process_directory(
265        &self,
266        dir: &Path,
267    ) -> Result<Vec<SerializerResult>, SerializerOutputError> {
268        let mut results = Vec::new();
269
270        for entry in fs::read_dir(dir)? {
271            let entry = entry?;
272            let path = entry.path();
273
274            if path.is_file() {
275                if is_serializer_source(&path) {
276                    match self.process_file(&path) {
277                        Ok(result) => results.push(result),
278                        Err(e) => {
279                            eprintln!("Warning: Failed to process {}: {}", path.display(), e);
280                        }
281                    }
282                }
283            }
284        }
285
286        Ok(results)
287    }
288
289    /// Check if outputs are up-to-date for a source file
290    pub fn is_up_to_date(&self, source_path: &Path) -> bool {
291        let paths = self.get_paths(source_path);
292
293        // Check if output files exist
294        if !paths.llm.exists() || !paths.machine.exists() {
295            return false;
296        }
297
298        // Compare modification times
299        let source_modified = fs::metadata(source_path).and_then(|m| m.modified()).ok();
300
301        let llm_modified = fs::metadata(&paths.llm).and_then(|m| m.modified()).ok();
302
303        let machine_modified = fs::metadata(&paths.machine).and_then(|m| m.modified()).ok();
304
305        match (source_modified, llm_modified, machine_modified) {
306            (Some(src), Some(llm), Some(machine)) => llm >= src && machine >= src,
307            _ => false,
308        }
309    }
310
311    /// Get the config
312    pub fn config(&self) -> &SerializerOutputConfig {
313        &self.config
314    }
315}
316
317/// Validate source/machine metadata before trusting a `.machine` cache artifact.
318#[cfg(feature = "converters")]
319pub fn validate_machine_metadata(
320    metadata_json: &str,
321    source_path: &Path,
322    source_bytes: &[u8],
323    machine_path: &Path,
324    machine_bytes: &[u8],
325) -> Result<(), SerializerOutputError> {
326    #[derive(serde::Deserialize)]
327    struct Metadata {
328        schema: String,
329        source: SourceMetadata,
330        machine: MachineMetadata,
331    }
332
333    #[derive(serde::Deserialize)]
334    struct SourceMetadata {
335        path: String,
336        bytes: usize,
337        blake3: String,
338    }
339
340    #[derive(serde::Deserialize)]
341    struct MachineMetadata {
342        path: String,
343        bytes: usize,
344        blake3: String,
345    }
346
347    let metadata: Metadata = serde_json::from_str(metadata_json).map_err(|error| {
348        SerializerOutputError::Parse(format!("Machine metadata JSON parse failed: {}", error))
349    })?;
350
351    if metadata.schema != "dx.machine.source_metadata.v1" {
352        return Err(SerializerOutputError::InvalidMetadata(format!(
353            "unsupported schema: {}",
354            metadata.schema
355        )));
356    }
357
358    validate_metadata_path("source path", &metadata.source.path, source_path)?;
359    validate_metadata_len("source bytes", metadata.source.bytes, source_bytes.len())?;
360    validate_metadata_hash("source blake3", &metadata.source.blake3, source_bytes)?;
361    validate_metadata_path("machine path", &metadata.machine.path, machine_path)?;
362    validate_metadata_len("machine bytes", metadata.machine.bytes, machine_bytes.len())?;
363    validate_metadata_hash("machine blake3", &metadata.machine.blake3, machine_bytes)?;
364
365    Ok(())
366}
367
368/// Validate source/machine metadata before trusting a `.machine` cache artifact.
369#[cfg(not(feature = "converters"))]
370pub fn validate_machine_metadata(
371    _metadata_json: &str,
372    _source_path: &Path,
373    _source_bytes: &[u8],
374    _machine_path: &Path,
375    _machine_bytes: &[u8],
376) -> Result<(), SerializerOutputError> {
377    Err(SerializerOutputError::Parse(
378        "Metadata validation requires the 'converters' feature".to_string(),
379    ))
380}
381
382fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
383    if let Some(parent) = path.parent() {
384        fs::create_dir_all(parent)?;
385    }
386
387    let temp_path = atomic_temp_path(path);
388    if temp_path.exists() {
389        fs::remove_file(&temp_path)?;
390    }
391
392    let mut file = fs::File::create(&temp_path)?;
393    file.write_all(bytes)?;
394    file.sync_all()?;
395    drop(file);
396
397    if path.exists() {
398        fs::remove_file(path)?;
399    }
400
401    fs::rename(&temp_path, path).inspect_err(|_| {
402        let _ = fs::remove_file(&temp_path);
403    })
404}
405
406fn atomic_temp_path(path: &Path) -> PathBuf {
407    let mut temp = path.to_path_buf();
408    let extension = path
409        .extension()
410        .and_then(|extension| extension.to_str())
411        .unwrap_or("tmp");
412    temp.set_extension(format!("{extension}.tmp"));
413    temp
414}
415
416#[cfg(feature = "converters")]
417fn validate_metadata_path(
418    label: &str,
419    expected: &str,
420    actual: &Path,
421) -> Result<(), SerializerOutputError> {
422    let actual = actual.display().to_string();
423    if expected == actual {
424        return Ok(());
425    }
426
427    Err(SerializerOutputError::InvalidMetadata(format!(
428        "{} mismatch: expected {}, found {}",
429        label, expected, actual
430    )))
431}
432
433#[cfg(feature = "converters")]
434fn validate_metadata_len(
435    label: &str,
436    expected: usize,
437    actual: usize,
438) -> Result<(), SerializerOutputError> {
439    if expected == actual {
440        return Ok(());
441    }
442
443    Err(SerializerOutputError::InvalidMetadata(format!(
444        "{} mismatch: expected {}, found {}",
445        label, expected, actual
446    )))
447}
448
449#[cfg(feature = "converters")]
450fn validate_metadata_hash(
451    label: &str,
452    expected: &str,
453    bytes: &[u8],
454) -> Result<(), SerializerOutputError> {
455    let actual = blake3::hash(bytes).to_hex().to_string();
456    if expected == actual {
457        return Ok(());
458    }
459
460    Err(SerializerOutputError::InvalidMetadata(format!(
461        "{} mismatch: expected {}, found {}",
462        label, expected, actual
463    )))
464}
465
466fn machine_metadata_json(
467    source_path: &Path,
468    source_bytes: &[u8],
469    machine_path: &Path,
470    machine_bytes: &[u8],
471) -> String {
472    let modified_unix_ms = fs::metadata(source_path)
473        .and_then(|metadata| metadata.modified())
474        .ok()
475        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
476        .map(|duration| duration.as_millis().to_string())
477        .unwrap_or_else(|| "null".to_string());
478
479    format!(
480        concat!(
481            "{{\n",
482            "  \"schema\": \"dx.machine.source_metadata.v1\",\n",
483            "  \"source\": {{\n",
484            "    \"path\": \"{}\",\n",
485            "    \"bytes\": {},\n",
486            "    \"modified_unix_ms\": {},\n",
487            "    \"blake3\": \"{}\"\n",
488            "  }},\n",
489            "  \"machine\": {{\n",
490            "    \"path\": \"{}\",\n",
491            "    \"bytes\": {},\n",
492            "    \"blake3\": \"{}\"\n",
493            "  }},\n",
494            "  \"cache\": {{\n",
495            "    \"rebuildable\": true,\n",
496            "    \"fallback_on_mismatch\": true\n",
497            "  }}\n",
498            "}}\n"
499        ),
500        json_escape(&source_path.display().to_string()),
501        source_bytes.len(),
502        modified_unix_ms,
503        blake3::hash(source_bytes).to_hex(),
504        json_escape(&machine_path.display().to_string()),
505        machine_bytes.len(),
506        blake3::hash(machine_bytes).to_hex()
507    )
508}
509
510fn json_escape(value: &str) -> String {
511    value
512        .chars()
513        .flat_map(|ch| match ch {
514            '"' => "\\\"".chars().collect::<Vec<_>>(),
515            '\\' => "\\\\".chars().collect::<Vec<_>>(),
516            '\n' => "\\n".chars().collect::<Vec<_>>(),
517            '\r' => "\\r".chars().collect::<Vec<_>>(),
518            '\t' => "\\t".chars().collect::<Vec<_>>(),
519            _ => vec![ch],
520        })
521        .collect()
522}
523
524fn parse_source_document(
525    source_path: &Path,
526    content: &str,
527) -> Result<DxDocument, SerializerOutputError> {
528    if is_json_source(source_path) {
529        return parse_json_document(content);
530    }
531    if is_toml_source(source_path) {
532        return parse_toml_document(content);
533    }
534
535    llm_to_document(content).map_err(|e| SerializerOutputError::Parse(e.to_string()))
536}
537
538#[cfg(feature = "converters")]
539fn parse_json_document(content: &str) -> Result<DxDocument, SerializerOutputError> {
540    json_to_document(content).map_err(SerializerOutputError::Parse)
541}
542
543#[cfg(not(feature = "converters"))]
544fn parse_json_document(_content: &str) -> Result<DxDocument, SerializerOutputError> {
545    Err(SerializerOutputError::Parse(
546        "JSON support requires the 'converters' feature".to_string(),
547    ))
548}
549
550#[cfg(feature = "converters")]
551fn parse_toml_document(content: &str) -> Result<DxDocument, SerializerOutputError> {
552    toml_to_document(content).map_err(SerializerOutputError::Parse)
553}
554
555#[cfg(not(feature = "converters"))]
556fn parse_toml_document(_content: &str) -> Result<DxDocument, SerializerOutputError> {
557    Err(SerializerOutputError::Parse(
558        "TOML support requires the 'converters' feature".to_string(),
559    ))
560}
561
562fn is_serializer_source(path: &Path) -> bool {
563    path.extension()
564        .and_then(|extension| extension.to_str())
565        .is_some_and(|extension| {
566            extension.eq_ignore_ascii_case("sr")
567                || extension.eq_ignore_ascii_case("dx")
568                || extension.eq_ignore_ascii_case("json")
569                || extension.eq_ignore_ascii_case("toml")
570        })
571        || path.file_name().and_then(|name| name.to_str()) == Some("dx")
572}
573
574fn is_json_source(path: &Path) -> bool {
575    path.extension()
576        .and_then(|extension| extension.to_str())
577        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
578}
579
580fn is_toml_source(path: &Path) -> bool {
581    path.extension()
582        .and_then(|extension| extension.to_str())
583        .is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
584}
585
586fn flatten_serializer_output_stem(source_path: &Path) -> String {
587    if source_path.file_name().and_then(|name| name.to_str()) == Some("dx") {
588        return "dx".to_string();
589    }
590
591    if is_json_source(source_path) || is_toml_source(source_path) {
592        if source_path.is_relative() {
593            let parts = source_path
594                .components()
595                .filter_map(|component| match component {
596                    Component::Normal(part) => part.to_str(),
597                    _ => None,
598                })
599                .map(sanitize_cache_stem_part)
600                .filter(|part| !part.is_empty())
601                .collect::<Vec<_>>();
602
603            if parts.len() > 1 {
604                return parts.join("-");
605            }
606        }
607
608        if let Some(file_name) = source_path.file_name().and_then(|name| name.to_str()) {
609            return sanitize_cache_stem_part(file_name);
610        }
611    }
612
613    let parts = source_path
614        .components()
615        .filter_map(|component| component.as_os_str().to_str())
616        .collect::<Vec<_>>();
617
618    if let Some(dx_index) = parts.iter().rposition(|part| *part == ".dx") {
619        let mut nested = parts
620            .iter()
621            .skip(dx_index + 1)
622            .filter(|part| **part != "serializer")
623            .map(|part| part.to_string())
624            .collect::<Vec<_>>();
625        if let Some(last) = nested.last_mut() {
626            if let Some((stem, _extension)) = last.rsplit_once('.') {
627                *last = stem.to_string();
628            }
629        }
630        let flattened = nested
631            .into_iter()
632            .filter(|part| !part.is_empty())
633            .collect::<Vec<_>>()
634            .join("-");
635        if !flattened.is_empty() {
636            return flattened;
637        }
638    }
639
640    source_path
641        .file_stem()
642        .map(|stem| sanitize_cache_stem_part(&stem.to_string_lossy()))
643        .filter(|stem| !stem.is_empty())
644        .unwrap_or_else(|| "unknown".to_string())
645}
646
647fn sanitize_cache_stem_part(part: &str) -> String {
648    let mut output = String::with_capacity(part.len());
649    let mut previous_was_dash = false;
650
651    for ch in part.chars() {
652        if ch.is_ascii_alphanumeric() || ch == '_' {
653            output.push(ch);
654            previous_was_dash = false;
655        } else if !previous_was_dash {
656            output.push('-');
657            previous_was_dash = true;
658        }
659    }
660
661    let trimmed = output.trim_matches('-');
662    if trimmed.is_empty() {
663        "path".to_string()
664    } else {
665        trimmed.to_string()
666    }
667}
668
669impl Default for SerializerOutput {
670    fn default() -> Self {
671        Self::new()
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::llm::convert::{MachineFormat, machine_to_document};
679    use tempfile::tempdir;
680
681    #[test]
682    fn test_serializer_output_config_default() {
683        let config = SerializerOutputConfig::default();
684        assert_eq!(config.output_dir, PathBuf::from(".dx/serializer"));
685        assert!(config.generate_llm);
686        assert!(config.generate_machine);
687    }
688
689    #[test]
690    fn test_get_paths() {
691        let output = SerializerOutput::new();
692        let paths = output.get_paths(Path::new("rules/javascript-lint.sr"));
693
694        assert_eq!(paths.llm.file_name().unwrap(), "javascript-lint.llm");
695        assert_eq!(
696            paths.machine.file_name().unwrap(),
697            "javascript-lint.machine"
698        );
699    }
700
701    #[test]
702    fn flatten_serializer_output_stem_uses_nested_dx_tool_path() {
703        let serializer = SerializerOutput::with_config(
704            SerializerOutputConfig::new().with_output_dir(".dx/serializer"),
705        );
706        let paths = serializer.get_paths(Path::new(".dx/forge/data.sr"));
707
708        assert_eq!(flatten_serializer_output_stem(Path::new("dx")), "dx");
709        assert_eq!(
710            paths.machine,
711            Path::new(".dx/serializer").join("forge-data.machine")
712        );
713    }
714
715    #[test]
716    fn flatten_serializer_output_stem_keeps_relative_config_identity() {
717        assert_eq!(
718            flatten_serializer_output_stem(Path::new("package.json")),
719            "package-json"
720        );
721        assert_eq!(
722            flatten_serializer_output_stem(Path::new("packages/bun-types/package.json")),
723            "packages-bun-types-package-json"
724        );
725        assert_eq!(
726            flatten_serializer_output_stem(Path::new("packages/@types/bun/package.json")),
727            "packages-types-bun-package-json"
728        );
729        assert_eq!(
730            flatten_serializer_output_stem(Path::new("bunfig.toml")),
731            "bunfig-toml"
732        );
733    }
734
735    #[test]
736    fn test_process_simple_file() {
737        let temp = tempdir().unwrap();
738        let source_path = temp.path().join("test.sr");
739
740        // Create a simple .sr file
741        fs::write(&source_path, "nm|test\nv|1.0").unwrap();
742
743        let config =
744            SerializerOutputConfig::new().with_output_dir(temp.path().join(".dx/serializer"));
745
746        let output = SerializerOutput::with_config(config);
747        let result = output.process_file(&source_path);
748
749        // Note: This may fail if llm_to_document doesn't support this format
750        // In that case, we'd need to adjust the test
751        if let Ok(result) = result {
752            assert!(result.llm_generated);
753            assert!(result.machine_generated);
754        }
755    }
756
757    #[test]
758    fn machine_metadata_validation_accepts_generated_metadata() {
759        let temp = tempdir().unwrap();
760        let source_path = temp.path().join("package.json");
761        fs::write(&source_path, r#"{"name":"dx-js-tool"}"#).unwrap();
762
763        let config = SerializerOutputConfig::new()
764            .with_output_dir(temp.path().join(".dx/js"))
765            .with_llm(false)
766            .with_machine(true)
767            .with_metadata(true)
768            .with_compression(CompressionAlgorithm::None);
769        let result = SerializerOutput::with_config(config)
770            .process_file(&source_path)
771            .unwrap();
772
773        let metadata = fs::read_to_string(&result.paths.metadata).unwrap();
774        let source_bytes = fs::read(&source_path).unwrap();
775        let machine_bytes = fs::read(&result.paths.machine).unwrap();
776
777        validate_machine_metadata(
778            &metadata,
779            &source_path,
780            &source_bytes,
781            &result.paths.machine,
782            &machine_bytes,
783        )
784        .unwrap();
785    }
786
787    #[test]
788    fn machine_metadata_validation_rejects_stale_source_bytes() {
789        let temp = tempdir().unwrap();
790        let source_path = temp.path().join("package.json");
791        fs::write(&source_path, r#"{"name":"dx-js-tool"}"#).unwrap();
792
793        let config = SerializerOutputConfig::new()
794            .with_output_dir(temp.path().join(".dx/js"))
795            .with_llm(false)
796            .with_machine(true)
797            .with_metadata(true)
798            .with_compression(CompressionAlgorithm::None);
799        let result = SerializerOutput::with_config(config)
800            .process_file(&source_path)
801            .unwrap();
802
803        let metadata = fs::read_to_string(&result.paths.metadata).unwrap();
804        let machine_bytes = fs::read(&result.paths.machine).unwrap();
805        let error = validate_machine_metadata(
806            &metadata,
807            &source_path,
808            br#"{"name":"changed123"}"#,
809            &result.paths.machine,
810            &machine_bytes,
811        )
812        .unwrap_err();
813
814        assert!(error.to_string().contains("source blake3 mismatch"));
815    }
816
817    #[test]
818    fn process_json_package_file_leaves_no_machine_temp_files() {
819        let temp = tempdir().unwrap();
820        let source_path = temp.path().join("package.json");
821        fs::write(&source_path, r#"{"name":"dx-js-tool"}"#).unwrap();
822
823        let config = SerializerOutputConfig::new()
824            .with_output_dir(temp.path().join(".dx/js"))
825            .with_llm(false)
826            .with_machine(true)
827            .with_metadata(true)
828            .with_compression(CompressionAlgorithm::None);
829        let result = SerializerOutput::with_config(config)
830            .process_file(&source_path)
831            .unwrap();
832
833        assert!(result.paths.machine.is_file());
834        assert!(result.paths.metadata.is_file());
835        assert!(!atomic_temp_path(&result.paths.machine).exists());
836        assert!(!atomic_temp_path(&result.paths.metadata).exists());
837    }
838
839    #[test]
840    fn process_canonical_dx_config_generates_readable_machine() {
841        let temp = tempdir().unwrap();
842        let source_path = temp.path().join("dx");
843        fs::write(
844            &source_path,
845            r#"
846project(name=dx-devtools version=0.1.0 kind=www-app)
847
848protected_crates[name](
849dx-www-browser
850dx-serializer
851)
852
853paths[name value](
854ui components/ui
855styles styles
856)
857
858tools[name command enabled output](
859serializer "dx serializer" true .dx/serializer
860style "dx style build" true styles/app.generated.css
861)
862"#,
863        )
864        .unwrap();
865
866        let config = SerializerOutputConfig::new()
867            .with_output_dir(temp.path().join(".dx/serializer"))
868            .with_llm(false)
869            .with_machine(true)
870            .with_compression(CompressionAlgorithm::None);
871        let result = SerializerOutput::with_config(config)
872            .process_file(&source_path)
873            .unwrap();
874
875        assert!(!result.llm_generated);
876        assert!(result.machine_generated);
877        assert!(result.paths.machine.is_file());
878        assert!(!result.paths.llm.exists());
879
880        let machine = MachineFormat::new(fs::read(&result.paths.machine).unwrap());
881        let document = machine_to_document(&machine).unwrap();
882
883        assert_eq!(
884            document.get_path("project.name").unwrap().as_str(),
885            Some("dx-devtools")
886        );
887        assert!(document.section_by_name("protected_crates").is_some());
888        assert_eq!(
889            document
890                .section_by_name("tools")
891                .unwrap()
892                .value_by_key("name", "style", "output")
893                .unwrap()
894                .as_str(),
895            Some("styles/app.generated.css")
896        );
897    }
898
899    #[test]
900    fn process_json_package_file_generates_js_machine_cache() {
901        let temp = tempdir().unwrap();
902        let source_path = temp.path().join("package.json");
903        fs::write(
904            &source_path,
905            r#"{
906                "name": "dx-js-tool",
907                "scripts": {
908                    "dev": "bun --watch src/index.tsx"
909                },
910                "dependencies": {
911                    "react": "latest"
912                },
913                "private": true
914            }"#,
915        )
916        .unwrap();
917
918        let config = SerializerOutputConfig::new()
919            .with_output_dir(temp.path().join(".dx/js"))
920            .with_llm(false)
921            .with_machine(true)
922            .with_metadata(true)
923            .with_compression(CompressionAlgorithm::None);
924        let result = SerializerOutput::with_config(config)
925            .process_file(&source_path)
926            .unwrap();
927
928        assert!(!result.llm_generated);
929        assert!(result.machine_generated);
930        assert_eq!(
931            result.paths.machine.file_name().unwrap(),
932            "package-json.machine"
933        );
934        assert_eq!(
935            result.paths.metadata.file_name().unwrap(),
936            "package-json.machine.meta.json"
937        );
938        assert!(result.paths.machine.is_file());
939        assert!(result.paths.metadata.is_file());
940        assert!(!result.paths.llm.exists());
941
942        let machine = MachineFormat::new(fs::read(&result.paths.machine).unwrap());
943        let document = machine_to_document(&machine).unwrap();
944
945        assert_eq!(
946            document.get_path("name").unwrap().as_str(),
947            Some("dx-js-tool")
948        );
949        assert_eq!(
950            document.get_path("scripts.dev").unwrap().as_str(),
951            Some("bun --watch src/index.tsx")
952        );
953        assert_eq!(
954            document.get_path("dependencies.react").unwrap().as_str(),
955            Some("latest")
956        );
957        assert_eq!(document.get_path("private").unwrap().as_bool(), Some(true));
958
959        let metadata = fs::read_to_string(&result.paths.metadata).unwrap();
960        let source_bytes = fs::read(&source_path).unwrap();
961        let machine_bytes = fs::read(&result.paths.machine).unwrap();
962        assert!(metadata.contains("\"schema\": \"dx.machine.source_metadata.v1\""));
963        assert!(metadata.contains(&format!("\"bytes\": {}", source_bytes.len())));
964        assert!(metadata.contains(&format!("\"{}\"", blake3::hash(&source_bytes).to_hex())));
965        assert!(metadata.contains(&format!("\"bytes\": {}", machine_bytes.len())));
966        assert!(metadata.contains(&format!("\"{}\"", blake3::hash(&machine_bytes).to_hex())));
967        assert!(metadata.contains("\"fallback_on_mismatch\": true"));
968    }
969
970    #[test]
971    fn process_toml_bunfig_file_generates_js_machine_cache() {
972        let temp = tempdir().unwrap();
973        let source_path = temp.path().join("bunfig.toml");
974        fs::write(
975            &source_path,
976            r#"
977telemetry = false
978
979[install]
980cache = true
981"#,
982        )
983        .unwrap();
984
985        let config = SerializerOutputConfig::new()
986            .with_output_dir(temp.path().join(".dx/js"))
987            .with_llm(false)
988            .with_machine(true)
989            .with_metadata(true)
990            .with_compression(CompressionAlgorithm::None);
991        let result = SerializerOutput::with_config(config)
992            .process_file(&source_path)
993            .unwrap();
994
995        assert!(!result.llm_generated);
996        assert!(result.machine_generated);
997        assert_eq!(
998            result.paths.machine.file_name().unwrap(),
999            "bunfig-toml.machine"
1000        );
1001        assert!(result.paths.machine.is_file());
1002        assert!(result.paths.metadata.is_file());
1003
1004        let machine = MachineFormat::new(fs::read(&result.paths.machine).unwrap());
1005        let document = machine_to_document(&machine).unwrap();
1006
1007        assert_eq!(
1008            document.get_path("telemetry").unwrap().as_bool(),
1009            Some(false)
1010        );
1011        assert_eq!(
1012            document.get_path("install.cache").unwrap().as_bool(),
1013            Some(true)
1014        );
1015    }
1016}