1#[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#[derive(Debug, Error)]
35pub enum SerializerOutputError {
36 #[error("IO error: {0}")]
38 Io(#[from] io::Error),
39
40 #[error("Parse error: {0}")]
42 Parse(String),
43
44 #[error("Conversion error: {0}")]
46 Convert(#[from] ConvertError),
47
48 #[error("Invalid path: {0}")]
50 InvalidPath(String),
51
52 #[error("Invalid metadata: {0}")]
54 InvalidMetadata(String),
55
56 #[error("Directory creation failed: {0}")]
58 DirectoryCreation(String),
59}
60
61#[derive(Debug, Clone)]
63pub struct SerializerOutputConfig {
64 pub output_dir: PathBuf,
66 pub generate_llm: bool,
68 pub generate_machine: bool,
70 pub compression: CompressionAlgorithm,
72 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 pub fn new() -> Self {
91 Self::default()
92 }
93
94 pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
96 self.output_dir = dir.into();
97 self
98 }
99
100 pub fn with_llm(mut self, generate: bool) -> Self {
102 self.generate_llm = generate;
103 self
104 }
105
106 pub fn with_machine(mut self, generate: bool) -> Self {
108 self.generate_machine = generate;
109 self
110 }
111
112 pub fn with_compression(mut self, compression: CompressionAlgorithm) -> Self {
114 self.compression = compression;
115 self
116 }
117
118 pub fn with_metadata(mut self, generate: bool) -> Self {
120 self.generate_metadata = generate;
121 self
122 }
123}
124
125#[derive(Debug, Clone)]
127pub struct SerializerPaths {
128 pub source: PathBuf,
130 pub llm: PathBuf,
132 pub machine: PathBuf,
134 pub metadata: PathBuf,
136}
137
138#[derive(Debug)]
140pub struct SerializerResult {
141 pub paths: SerializerPaths,
143 pub llm_generated: bool,
145 pub machine_generated: bool,
147 pub llm_size: usize,
149 pub machine_size: usize,
151}
152
153pub struct SerializerOutput {
155 config: SerializerOutputConfig,
156}
157
158impl SerializerOutput {
159 pub fn new() -> Self {
161 Self {
162 config: SerializerOutputConfig::default(),
163 }
164 }
165
166 pub fn with_config(config: SerializerOutputConfig) -> Self {
168 Self { config }
169 }
170
171 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 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 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 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 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 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 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 pub fn is_up_to_date(&self, source_path: &Path) -> bool {
291 let paths = self.get_paths(source_path);
292
293 if !paths.llm.exists() || !paths.machine.exists() {
295 return false;
296 }
297
298 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 pub fn config(&self) -> &SerializerOutputConfig {
313 &self.config
314 }
315}
316
317#[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#[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 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 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}