@@ -55,10 +55,10 @@ impl ResolveExecution {
5555pub fn parse_resolve_request ( args : Option < & Value > ) -> Result < ResolveDatasheetInput > {
5656 let args = args. ok_or_else ( || anyhow:: anyhow!( "arguments required" ) ) ?;
5757
58- let datasheet_url = optional_trimmed_string ( args, "datasheet_url" ) ;
59- let pdf_path = optional_path ( args, "pdf_path" ) ;
60- let kicad_sym_path = optional_path ( args, "kicad_sym_path" ) ;
61- let symbol_name = optional_trimmed_string ( args, "symbol_name" ) ;
58+ let datasheet_url = optional_trimmed_string_any ( args, & [ "datasheet_url" , "datasheetUrl" ] ) ;
59+ let pdf_path = optional_path_any ( args, & [ "pdf_path" , "pdfPath" ] ) ;
60+ let kicad_sym_path = optional_path_any ( args, & [ "kicad_sym_path" , "kicadSymPath" ] ) ;
61+ let symbol_name = optional_trimmed_string_any ( args, & [ "symbol_name" , "symbolName" ] ) ;
6262
6363 if symbol_name. is_some ( ) && kicad_sym_path. is_none ( ) {
6464 anyhow:: bail!( "symbol_name requires kicad_sym_path" ) ;
@@ -114,14 +114,15 @@ fn resolve_source_url_datasheet(
114114 let url_cache_dir = url_pdf_cache_dir ( & canonical_url) ?;
115115 fs:: create_dir_all ( & url_cache_dir) ?;
116116
117- let ( pdf_path, prefetched_process) =
118- if let Some ( cached_pdf) = first_valid_cached_pdf ( & url_cache_dir) ? {
119- ( cached_pdf, None )
120- } else {
121- let ( process, downloaded_pdf_path) =
122- fetch_url_pdf_via_backend ( client, auth_token, & canonical_url, & url_cache_dir) ?;
123- ( downloaded_pdf_path, Some ( process) )
124- } ;
117+ let ( pdf_path, prefetched_process) = if let Some ( cached_pdf) =
118+ first_valid_file_in_dir ( & url_cache_dir, None , is_valid_cached_pdf) ?
119+ {
120+ ( cached_pdf, None )
121+ } else {
122+ let ( process, downloaded_pdf_path) =
123+ fetch_url_pdf_via_backend ( client, auth_token, & canonical_url, & url_cache_dir) ?;
124+ ( downloaded_pdf_path, Some ( process) )
125+ } ;
125126
126127 let execution = ResolveExecution :: from_pdf_path ( pdf_path, Some ( canonical_url) ) ?;
127128 execute_resolve_execution ( client, auth_token, execution, prefetched_process)
@@ -137,20 +138,26 @@ fn execute_resolve_execution(
137138 let materialization_id = materialization_id_for_key ( & execution. pdf_sha256 ) ?;
138139 let materialized_dir = materialized_dir ( & materialization_id) ;
139140 let markdown_path = materialized_dir. join ( inferred_markdown_filename ( & execution. pdf_path ) ) ;
140- let cached_markdown_path = first_valid_cached_markdown ( & materialized_dir, & markdown_path) ?;
141+ let cached_markdown_path = first_valid_file_in_dir (
142+ & materialized_dir,
143+ Some ( & markdown_path) ,
144+ is_valid_markdown_file,
145+ ) ?;
141146 let images_dir = materialized_dir. join ( "images" ) ;
142147 let complete_marker = materialized_dir. join ( ".complete" ) ;
143148 let has_materialized_cache = cached_markdown_path. is_some ( )
144149 && images_dir. is_dir ( )
145150 && is_non_empty_file ( & complete_marker) ?;
146151
147- if has_materialized_cache && is_valid_cached_pdf ( & execution . pdf_path ) ? {
152+ if has_materialized_cache {
148153 let cached_markdown_path = cached_markdown_path
149154 . context ( "Materialized cache is marked complete but markdown file is missing" ) ?;
155+ let materialized_pdf_path =
156+ ensure_materialized_pdf ( & materialized_dir, & execution. pdf_path ) ?;
150157 return Ok ( build_resolve_response (
151158 & cached_markdown_path,
152159 & images_dir,
153- & execution . pdf_path ,
160+ & materialized_pdf_path ,
154161 execution. datasheet_url ,
155162 ) ) ;
156163 }
@@ -193,11 +200,12 @@ fn execute_resolve_execution(
193200 & images_dir,
194201 & complete_marker,
195202 ) ?;
203+ let materialized_pdf_path = ensure_materialized_pdf ( & materialized_dir, & execution. pdf_path ) ?;
196204
197205 Ok ( build_resolve_response (
198206 & markdown_path,
199207 & images_dir,
200- & execution . pdf_path ,
208+ & materialized_pdf_path ,
201209 execution. datasheet_url ,
202210 ) )
203211}
@@ -397,6 +405,11 @@ fn optional_trimmed_string(args: &Value, key: &str) -> Option<String> {
397405 . map ( ToOwned :: to_owned)
398406}
399407
408+ fn optional_trimmed_string_any ( args : & Value , keys : & [ & str ] ) -> Option < String > {
409+ keys. iter ( )
410+ . find_map ( |key| optional_trimmed_string ( args, key) )
411+ }
412+
400413fn optional_path ( args : & Value , key : & str ) -> Option < PathBuf > {
401414 args. get ( key)
402415 . and_then ( |v| v. as_str ( ) )
@@ -405,6 +418,10 @@ fn optional_path(args: &Value, key: &str) -> Option<PathBuf> {
405418 . map ( PathBuf :: from)
406419}
407420
421+ fn optional_path_any ( args : & Value , keys : & [ & str ] ) -> Option < PathBuf > {
422+ keys. iter ( ) . find_map ( |key| optional_path ( args, key) )
423+ }
424+
408425fn canonicalize_url ( url : & str ) -> Result < String > {
409426 let mut parsed = Url :: parse ( url) . with_context ( || format ! ( "Invalid datasheet_url: {url}" ) ) ?;
410427 if !matches ! ( parsed. scheme( ) , "http" | "https" ) {
@@ -473,8 +490,18 @@ fn url_pdf_cache_dir(canonical_url: &str) -> Result<PathBuf> {
473490 Ok ( url_pdf_cache_root_dir ( ) . join ( key) )
474491}
475492
476- fn first_valid_cached_pdf ( url_cache_dir : & Path ) -> Result < Option < PathBuf > > {
477- let entries = match fs:: read_dir ( url_cache_dir) {
493+ fn first_valid_file_in_dir (
494+ dir : & Path ,
495+ preferred_path : Option < & Path > ,
496+ is_valid : fn ( & Path ) -> Result < bool > ,
497+ ) -> Result < Option < PathBuf > > {
498+ if let Some ( path) = preferred_path
499+ && is_valid ( path) ?
500+ {
501+ return Ok ( Some ( path. to_path_buf ( ) ) ) ;
502+ }
503+
504+ let entries = match fs:: read_dir ( dir) {
478505 Ok ( entries) => entries,
479506 Err ( err) if err. kind ( ) == std:: io:: ErrorKind :: NotFound => return Ok ( None ) ,
480507 Err ( err) => return Err ( err. into ( ) ) ,
@@ -483,45 +510,60 @@ fn first_valid_cached_pdf(url_cache_dir: &Path) -> Result<Option<PathBuf>> {
483510 for entry in entries {
484511 let entry = entry?;
485512 let path = entry. path ( ) ;
486- if path . is_file ( ) && is_valid_cached_pdf ( & path) ? {
513+ if is_valid ( & path) ? {
487514 return Ok ( Some ( path) ) ;
488515 }
489516 }
490517
491518 Ok ( None )
492519}
493520
494- fn first_valid_cached_markdown (
495- materialized_dir : & Path ,
496- preferred_markdown_path : & Path ,
497- ) -> Result < Option < PathBuf > > {
498- if is_non_empty_file ( preferred_markdown_path) ? {
499- return Ok ( Some ( preferred_markdown_path. to_path_buf ( ) ) ) ;
521+ fn ensure_materialized_pdf ( materialized_dir : & Path , source_pdf_path : & Path ) -> Result < PathBuf > {
522+ fs:: create_dir_all ( materialized_dir) ?;
523+ let preferred_pdf_path = materialized_dir. join ( inferred_pdf_filename ( source_pdf_path) ) ;
524+ if let Some ( existing_pdf_path) = first_valid_file_in_dir (
525+ materialized_dir,
526+ Some ( & preferred_pdf_path) ,
527+ is_valid_materialized_pdf_file,
528+ ) ? {
529+ return Ok ( existing_pdf_path) ;
530+ }
531+
532+ fs:: copy ( source_pdf_path, & preferred_pdf_path)
533+ . with_context ( || format ! ( "Failed to copy PDF into {}" , preferred_pdf_path. display( ) ) ) ?;
534+ if !is_valid_cached_pdf ( & preferred_pdf_path) ? {
535+ let _ = fs:: remove_file ( & preferred_pdf_path) ;
536+ anyhow:: bail!(
537+ "Copied PDF in materialized cache is invalid: {}" ,
538+ preferred_pdf_path. display( )
539+ ) ;
500540 }
501541
502- let entries = match fs:: read_dir ( materialized_dir) {
503- Ok ( entries) => entries,
504- Err ( err) if err. kind ( ) == std:: io:: ErrorKind :: NotFound => return Ok ( None ) ,
505- Err ( err) => return Err ( err. into ( ) ) ,
506- } ;
542+ Ok ( preferred_pdf_path)
543+ }
507544
508- for entry in entries {
509- let entry = entry?;
510- let path = entry. path ( ) ;
511- if is_markdown_file ( & path) && is_non_empty_file ( & path) ? {
512- return Ok ( Some ( path) ) ;
513- }
514- }
545+ fn is_markdown_file ( path : & Path ) -> bool {
546+ is_file_with_extension ( path, "md" )
547+ }
515548
516- Ok ( None )
549+ fn is_pdf_file ( path : & Path ) -> bool {
550+ is_file_with_extension ( path, "pdf" )
517551}
518552
519- fn is_markdown_file ( path : & Path ) -> bool {
553+ fn is_valid_markdown_file ( path : & Path ) -> Result < bool > {
554+ Ok ( is_markdown_file ( path) && is_non_empty_file ( path) ?)
555+ }
556+
557+ fn is_valid_materialized_pdf_file ( path : & Path ) -> Result < bool > {
558+ Ok ( is_pdf_file ( path) && is_valid_cached_pdf ( path) ?)
559+ }
560+
561+ fn is_file_with_extension ( path : & Path , extension : & str ) -> bool {
520562 path. is_file ( )
521563 && path
522564 . extension ( )
523565 . and_then ( |ext| ext. to_str ( ) )
524- . is_some_and ( |ext| ext. eq_ignore_ascii_case ( "md" ) )
566+ . is_some_and ( |ext| ext. eq_ignore_ascii_case ( extension ) )
525567}
526568
527569fn infer_source_pdf_filename ( source_pdf_url : & str ) -> Result < String > {
@@ -549,6 +591,10 @@ fn is_non_empty_file(path: &Path) -> Result<bool> {
549591}
550592
551593fn is_valid_cached_pdf ( path : & Path ) -> Result < bool > {
594+ if !path. is_file ( ) {
595+ return Ok ( false ) ;
596+ }
597+
552598 let mut file = match File :: open ( path) {
553599 Ok ( file) => file,
554600 Err ( err) if err. kind ( ) == std:: io:: ErrorKind :: NotFound => return Ok ( false ) ,
@@ -629,12 +675,22 @@ mod tests {
629675 let pdf_path = dir. join ( "blob" ) ;
630676 fs:: write ( & pdf_path, b"%PDF-1.7\n " ) . unwrap ( ) ;
631677
632- let found = first_valid_cached_pdf ( & dir) . unwrap ( ) ;
678+ let found = first_valid_file_in_dir ( & dir, None , is_valid_cached_pdf ) . unwrap ( ) ;
633679 assert_eq ! ( found. as_deref( ) , Some ( pdf_path. as_path( ) ) ) ;
634680
635681 fs:: remove_dir_all ( dir) . unwrap ( ) ;
636682 }
637683
684+ #[ test]
685+ fn test_is_valid_cached_pdf_returns_false_for_directory ( ) {
686+ let dir = std:: env:: temp_dir ( ) . join ( format ! ( "datasheet-cache-dir-{}" , Uuid :: new_v4( ) ) ) ;
687+ fs:: create_dir_all ( & dir) . unwrap ( ) ;
688+
689+ assert ! ( !is_valid_cached_pdf( & dir) . unwrap( ) ) ;
690+
691+ fs:: remove_dir_all ( dir) . unwrap ( ) ;
692+ }
693+
638694 #[ test]
639695 fn test_first_valid_cached_markdown_falls_back_to_existing_markdown ( ) {
640696 let dir = std:: env:: temp_dir ( ) . join ( format ! ( "datasheet-md-cache-dir-{}" , Uuid :: new_v4( ) ) ) ;
@@ -643,12 +699,33 @@ mod tests {
643699 fs:: write ( & existing_markdown, b"# Datasheet\n " ) . unwrap ( ) ;
644700 let preferred_markdown = dir. join ( "datasheet.md" ) ;
645701
646- let found = first_valid_cached_markdown ( & dir, & preferred_markdown) . unwrap ( ) ;
702+ let found =
703+ first_valid_file_in_dir ( & dir, Some ( & preferred_markdown) , is_valid_markdown_file)
704+ . unwrap ( ) ;
647705 assert_eq ! ( found. as_deref( ) , Some ( existing_markdown. as_path( ) ) ) ;
648706
649707 fs:: remove_dir_all ( dir) . unwrap ( ) ;
650708 }
651709
710+ #[ test]
711+ fn test_ensure_materialized_pdf_reuses_existing_pdf_name ( ) {
712+ let dir = std:: env:: temp_dir ( ) . join ( format ! ( "datasheet-pdf-cache-dir-{}" , Uuid :: new_v4( ) ) ) ;
713+ fs:: create_dir_all ( & dir) . unwrap ( ) ;
714+
715+ let existing_pdf = dir. join ( "ad574a.pdf" ) ;
716+ fs:: write ( & existing_pdf, b"%PDF-1.7\n existing" ) . unwrap ( ) ;
717+
718+ let source_pdf =
719+ std:: env:: temp_dir ( ) . join ( format ! ( "datasheet-source-{}.pdf" , Uuid :: new_v4( ) ) ) ;
720+ fs:: write ( & source_pdf, b"%PDF-1.7\n source" ) . unwrap ( ) ;
721+
722+ let materialized = ensure_materialized_pdf ( & dir, & source_pdf) . unwrap ( ) ;
723+ assert_eq ! ( materialized, existing_pdf) ;
724+
725+ fs:: remove_dir_all ( dir) . unwrap ( ) ;
726+ fs:: remove_file ( source_pdf) . unwrap ( ) ;
727+ }
728+
652729 #[ test]
653730 fn test_extract_datasheet_url_from_symbols_uses_first_valid_value ( ) {
654731 let source = r#"(kicad_symbol_lib
@@ -691,6 +768,38 @@ mod tests {
691768 assert ! ( parse_resolve_request( Some ( & args) ) . is_err( ) ) ;
692769 }
693770
771+ #[ test]
772+ fn test_parse_request_accepts_camel_case_datasheet_url ( ) {
773+ let args = serde_json:: json!( {
774+ "datasheetUrl" : "https://example.com/a.pdf"
775+ } ) ;
776+ let parsed = parse_resolve_request ( Some ( & args) ) . unwrap ( ) ;
777+ match parsed {
778+ ResolveDatasheetInput :: DatasheetUrl ( url) => {
779+ assert_eq ! ( url, "https://example.com/a.pdf" )
780+ }
781+ _ => panic ! ( "expected DatasheetUrl input" ) ,
782+ }
783+ }
784+
785+ #[ test]
786+ fn test_parse_request_accepts_camel_case_pdf_path ( ) {
787+ let file = std:: env:: temp_dir ( ) . join ( format ! ( "datasheet-input-{}.pdf" , Uuid :: new_v4( ) ) ) ;
788+ fs:: write ( & file, b"%PDF-1.7\n " ) . unwrap ( ) ;
789+
790+ let args = serde_json:: json!( {
791+ "pdfPath" : file. display( ) . to_string( )
792+ } ) ;
793+
794+ let parsed = parse_resolve_request ( Some ( & args) ) . unwrap ( ) ;
795+ match parsed {
796+ ResolveDatasheetInput :: PdfPath ( path) => assert_eq ! ( path, file) ,
797+ _ => panic ! ( "expected PdfPath input" ) ,
798+ }
799+
800+ fs:: remove_file ( file) . unwrap ( ) ;
801+ }
802+
694803 #[ test]
695804 fn test_parse_request_trims_pdf_path ( ) {
696805 let path = std:: env:: temp_dir ( ) . join ( format ! ( "datasheet-test-{}.pdf" , Uuid :: new_v4( ) ) ) ;
@@ -768,6 +877,7 @@ mod tests {
768877 assert ! ( value. get( "images_dir" ) . is_some( ) ) ;
769878 assert ! ( value. get( "pdf_path" ) . is_some( ) ) ;
770879 assert ! ( value. get( "datasheet_url" ) . is_some( ) ) ;
880+ assert ! ( value. get( "materialized_dir" ) . is_none( ) ) ;
771881 assert ! ( value. get( "sha256" ) . is_none( ) ) ;
772882 assert ! ( value. get( "source_pdf_url" ) . is_none( ) ) ;
773883 }
0 commit comments