Skip to content

Commit 5c28953

Browse files
authored
Add resolve_datasheet LSP endpoint (#574)
* Store PDF alongside materialized datasheet outputs Copy or reuse the PDF inside the content-hash materialized directory so markdown, images, and PDF live together. Return pdf_path from the materialized directory and keep pretty filenames, while deduplicating cache lookup paths. * Add resolve_datasheet LSP endpoint Add a simple custom LSP request hook and expose resolve_datasheet as pcb/resolveDatasheet. Keep MCP and LSP thin by using direct parse/auth/resolve callsites, and accept camelCase or snake_case params in shared parsing.
1 parent c44d87c commit 5c28953

File tree

6 files changed

+275
-44
lines changed

6 files changed

+275
-44
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to Semantic Versioning (https://semver.org/spec/v2.0.0.
1111
### Added
1212

1313
- Added MCP tool `resolve_datasheet` to produce cached `datasheet.md` + `images/` from `datasheet_url`, `pdf_path`, or `kicad_sym_path`.
14+
- Added LSP request `pcb/resolveDatasheet`, sharing the same resolve flow as the MCP tool.
1415

1516
## [0.3.44] - 2026-02-20
1617

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pcb-diode-api/src/datasheet.rs

Lines changed: 152 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ impl ResolveExecution {
5555
pub 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+
400413
fn 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+
408425
fn 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

527569
fn 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

551593
fn 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\nexisting").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\nsource").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

Comments
 (0)