From 0b3e16578402acadc677c8787188f21166d486fc Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 25 Aug 2026 00:06:05 +0200 Subject: [PATCH] feat(intellisense): answer from the local sketchbook, not only the cache The symbol server assumed one library layout: the content-addressed cache, where an entry dir name IS its content hash. A host without that cache -- Velxio Desktop's sidecar, and any self-host -- has its libraries where arduino-cli put them, under plain folder names with the version inside library.properties, and got a 404 for every request. The editor there falls back to the engine's embedded catalog, so `client.` on a library the user installed himself resolves to nothing. Both layouts are now sources, tried best-first. The only real difference is mutability: `lib upgrade` rewrites a sketchbook folder in place and keeps its name, so a scan cannot be keyed by that name. Sketchbook entries are keyed by a cheap stat-only stamp (file count, total size, newest mtime) in the same `@-<12 hex>` shape, which makes the disk cache exact rather than merely long-lived. Cache entries keep their existing keys, so no deployed scan is invalidated. Where the sketchbook is: an explicit override, then the sketchbook env vars, then arduino-cli itself (`config get directories.user`, probed once and remembered) -- guessing ~/Arduino is wrong on the platforms whose default is ~/Documents/Arduino. A sketchbook that resolves to the cache is dropped: the pro overlay points arduino-cli at a symlink to the cache root, and scanning it twice under two shapes buys nothing. Scans for a sketchbook are written to `/.velxio-symbols/`, beside `libraries/` and never inside it, so nothing Velxio writes shows up in `arduino-cli lib list`. The type index now keys on a fingerprint of the source's entry keys instead of the entry count, which is exact for both layouts (an in-place upgrade changes no count). Deployed indexes rebuild once, ~1s from the cached scans. --- backend/app/api/routes/intellisense.py | 291 ++++++++++++++---- .../unit/test_intellisense_local_libraries.py | 181 +++++++++++ 2 files changed, 419 insertions(+), 53 deletions(-) create mode 100644 test/backend/unit/test_intellisense_local_libraries.py diff --git a/backend/app/api/routes/intellisense.py b/backend/app/api/routes/intellisense.py index 707ee833..a11606d5 100644 --- a/backend/app/api/routes/intellisense.py +++ b/backend/app/api/routes/intellisense.py @@ -1,34 +1,47 @@ """Intellisense symbol server. Serves editor symbol tables (classes, public methods, constants, enum values) -for Arduino libraries present in the server-side content-addressed library -cache (VELXIO_LIBCACHE_DIR, default /var/velxio/libcache — entries named -`@-`). Extraction is done by the +for the Arduino libraries a host actually has. Extraction is done by the dependency-free scanner in app.services.symbol_extract, which also mirrors the pro overlay's cache naming rules; this module never imports app.pro. -Scans are cached on disk under `/.symbols/.json`: entry -names are content-addressed, so a cached scan can never go stale and the -response is safe to cache aggressively client-side too. A host with no cache -dir (plain OSS self-host) simply 404s — the frontend degrades gracefully. +Two library layouts answer this endpoint, in that order: + + 1. The content-addressed library cache (VELXIO_LIBCACHE_DIR, default + /var/velxio/libcache — entry dirs named `@- + `). Immutable by construction, so a scan cached under an entry + name can never go stale and the response is safe to cache hard. + 2. A plain arduino-cli sketchbook `libraries/` dir — what Velxio Desktop's + sidecar and a self-host have. Entry dirs are named after the library and + the version lives in library.properties, and they are MUTABLE (`lib + upgrade` rewrites one in place), so scans are keyed by a cheap content + stamp instead of by the dir name. + +A host with neither simply 404s and the editor degrades to the engine's +embedded catalog. """ from __future__ import annotations +import hashlib import json import logging import os import re +import subprocess import tempfile import time from pathlib import Path +from typing import NamedTuple, Optional from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse from app.services.symbol_extract import ( _entry_version_key, + _read_props, find_cache_entry, norm_name, + norm_version, parse_spec, scan_library, ) @@ -42,12 +55,170 @@ router = APIRouter() _CACHE_HEADERS = {"Cache-Control": "public, max-age=86400"} +class _Source(NamedTuple): + """One library layout this endpoint can answer from. `content_addressed` + is the whole difference: it decides how an entry is found, how its scan is + keyed, and where the scan cache is written.""" + + root: Path + content_addressed: bool + + def _cache_root() -> Path: """Resolved per-request so tests (and container reconfigs) can point the endpoint elsewhere via the environment.""" return Path(os.environ.get("VELXIO_LIBCACHE_DIR", "/var/velxio/libcache")) +# arduino-cli's own sketchbook, asked once and remembered: on the desktop +# sidecar nobody sets an env var for it, and guessing ~/Arduino is wrong on +# the platforms where the default is ~/Documents/Arduino. Whatever arduino-cli +# answers is by definition the dir the compile resolves libraries from. +_CLI_PROBE_TTL_S = 600 +_cli_probe: tuple[float, Optional[Path]] = (0.0, None) + + +def _cli_sketchbook() -> Optional[Path]: + global _cli_probe + ts, cached = _cli_probe + now = time.time() + if ts and now - ts < _CLI_PROBE_TTL_S: + return cached + found: Optional[Path] = None + try: + out = subprocess.run( + ["arduino-cli", "config", "get", "directories.user"], + capture_output=True, text=True, timeout=5, + ) + if out.returncode == 0: + p = Path(out.stdout.strip().strip('"')) + if p.is_dir(): + found = p + except (OSError, subprocess.SubprocessError): + pass # no arduino-cli on PATH (web image is fine — it has the cache) + _cli_probe = (now, found) + return found + + +def _sketchbook_libraries() -> Optional[Path]: + """The `libraries/` dir of the local sketchbook, or None. + + VELXIO_SYMBOL_LIBRARIES_DIR names the libraries dir itself (the explicit + override, for a host that keeps its libraries somewhere unusual); the + sketchbook env vars name its parent; arduino-cli is asked last but knows + best. The home-dir guesses are only reached when arduino-cli cannot be + run at all. + """ + direct = os.environ.get("VELXIO_SYMBOL_LIBRARIES_DIR") + if direct and Path(direct).is_dir(): + return Path(direct) + for env in ("ARDUINO_DIRECTORIES_USER", "VELXIO_FALLBACK_SKETCHBOOK"): + val = os.environ.get(env) + if val and (Path(val) / "libraries").is_dir(): + return Path(val) / "libraries" + probed = _cli_sketchbook() + if probed is not None and (probed / "libraries").is_dir(): + return probed / "libraries" + home = Path.home() + for rel in ("Arduino/libraries", "Documents/Arduino/libraries"): + if (home / rel).is_dir(): + return home / rel + return None + + +def _sources() -> list[_Source]: + """The layouts to try, best first. The sketchbook is skipped when it + resolves to the cache itself: the pro overlay points arduino-cli's + sketchbook at `/../cache-sketchbook/libraries`, a symlink to the + cache root, and scanning it twice under two shapes buys nothing.""" + out: list[_Source] = [] + cache = _cache_root() + if cache.is_dir(): + out.append(_Source(cache, True)) + libs = _sketchbook_libraries() + if libs is not None: + try: + same = any(s.root.resolve() == libs.resolve() for s in out) + except OSError: + same = False + if not same: + out.append(_Source(libs, False)) + return out + + +def _stamp(entry: Path) -> str: + """Content fingerprint of a MUTABLE library dir: file count, total size and + newest mtime over the tree, stat only — no reads. Changes whenever + arduino-cli installs or upgrades the library, which is what lets a scan be + cached on disk without ever going stale.""" + newest = 0.0 + total = 0 + count = 0 + for parent, dirs, files in os.walk(entry): + dirs[:] = [d for d in dirs if not d.startswith(".")] + for f in files: + try: + st = os.stat(os.path.join(parent, f)) + except OSError: + continue + count += 1 + total += st.st_size + newest = max(newest, st.st_mtime) + raw = f"{count}:{total}:{newest:.6f}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12] + + +def _entry_key(src: _Source, entry: Path) -> str: + """Cache key for one entry's scan. A content-addressed entry IS its key; + a sketchbook folder gets the same `@-<12 hex>` + shape built from its properties plus the content stamp.""" + if src.content_addressed: + return entry.name + props = _read_props(entry) + name = norm_name(props.get("name", "")) or norm_name(entry.name) or "lib" + return f"{name}@{norm_version(props.get('version', ''))}-{_stamp(entry)}" + + +def _symbols_dir(src: _Source) -> Path: + """Where scans are persisted. Inside the cache root for the cache; beside + the sketchbook's `libraries/` for a sketchbook, so nothing Velxio writes + ever lands where arduino-cli looks for libraries.""" + if src.content_addressed: + return src.root / ".symbols" + return src.root.parent / ".velxio-symbols" + + +def _entries(src: _Source) -> list[Path]: + try: + return sorted( + d for d in src.root.iterdir() if d.is_dir() and not d.name.startswith(".") + ) + except OSError: + return [] + + +def _find_entry(src: _Source, name: str, version: Optional[str]) -> Optional[Path]: + if src.content_addressed: + return find_cache_entry(src.root, name, version) + # A sketchbook folder is matched on library.properties `name=` (or the + # folder name), and the version is a PREFERENCE, not a filter: the local + # build compiles against whatever is installed, so answering with the + # installed version beats 404ing because the project pinned another one. + want = norm_name(name) + if not want: + return None + fallback: Optional[Path] = None + for d in _entries(src): + props = _read_props(d) + if norm_name(props.get("name", "")) != want and norm_name(d.name) != want: + continue + if version is None or norm_version(props.get("version", "")) == norm_version(version): + return d + if fallback is None: + fallback = d + return fallback + + def _write_symbol_cache(sym_file: Path, payload: dict) -> None: """Atomically persist a scan result (tmp file + rename). Failures are logged and ignored — the response was already computed.""" @@ -68,9 +239,9 @@ def _write_symbol_cache(sym_file: Path, payload: dict) -> None: logger.warning("[intellisense] symbol cache write failed for %s: %s", sym_file, e) -def _payload_for(root: Path, entry: Path) -> dict: - """Cached scan of one entry (same disk cache get_symbols uses).""" - sym_file = root / ".symbols" / f"{entry.name}.json" +def _payload_for(src: _Source, entry: Path) -> dict: + """Cached scan of one entry (the same disk cache get_symbols uses).""" + sym_file = _symbols_dir(src) / f"{_entry_key(src, entry)}.json" if sym_file.is_file(): try: return json.loads(sym_file.read_text(encoding="utf-8")) @@ -86,23 +257,30 @@ _TYPE_INDEX_MAX_AGE_S = 6 * 3600 _MAX_BASES = 8 -def _type_index(root: Path) -> dict[str, str]: - """owner-class name -> cache entry dir name, over the whole library - cache. Built by scanning every entry once (each scan is itself disk- - cached), persisted under .symbols/_types.json and rebuilt when older than - a few hours or when the cache has grown. When two entries declare the - same class (a library and its fork, or two versions), the newest entry - for the alphabetically-first library wins -- deterministic and, for the - common case of one library per class, irrelevant.""" - idx_file = root / ".symbols" / _TYPE_INDEX_NAME - entries = sorted( - d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith(".") - ) +def _index_key(src: _Source, entries: list[Path]) -> str: + """Fingerprint of the whole source. Entry keys already move whenever any + library's bytes change, so an index built under this key describes exactly + the libraries present.""" + joined = ",".join(_entry_key(src, e) for e in entries) + return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:12] + + +def _type_index(src: _Source) -> dict[str, str]: + """owner-class name -> entry dir name, over the whole source. Built by + scanning every entry once (each scan is itself disk-cached), persisted + next to the scans and rebuilt when the source's fingerprint moves or the + index ages out. When two entries declare the same class (a library and + its fork, or two versions), the newest entry for the alphabetically-first + library wins -- deterministic and, for the common case of one library per + class, irrelevant.""" + idx_file = _symbols_dir(src) / _TYPE_INDEX_NAME + entries = _entries(src) + key = _index_key(src, entries) if idx_file.is_file(): try: cached = json.loads(idx_file.read_text(encoding="utf-8")) fresh = (time.time() - idx_file.stat().st_mtime) < _TYPE_INDEX_MAX_AGE_S - if fresh and cached.get("entry_count") == len(entries): + if fresh and cached.get("index_key") == key: return cached["types"] except (OSError, ValueError, KeyError): pass @@ -117,12 +295,14 @@ def _type_index(root: Path) -> dict[str, str]: # 3. ties: alphabetical, deterministic. best: dict[str, tuple[int, int, str]] = {} # owner -> (name_match, member_count, entry) with a sortable key by_name: dict[str, list[Path]] = {} - for name in entries: - by_name.setdefault(name.partition("@")[0], []).append(root / name) + for entry in entries: + # Cache entries carry the version in the dir name, so several map to + # one library; a sketchbook holds one dir per library already. + by_name.setdefault(entry.name.partition("@")[0], []).append(entry) for lib_name in sorted(by_name): newest = sorted(by_name[lib_name], key=_entry_version_key, reverse=True)[0] try: - payload = _payload_for(root, newest) + payload = _payload_for(src, newest) except Exception as e: # noqa: BLE001 - one bad library must not kill the index logger.warning("[intellisense] type index: skipping %s: %s", newest.name, e) continue @@ -132,7 +312,7 @@ def _type_index(root: Path) -> dict[str, str]: if owner: counts[owner] = counts.get(owner, 0) + 1 for owner, n in counts.items(): - name_match = 1 if norm_name(owner) == lib_name else 0 + name_match = 1 if norm_name(owner) == norm_name(lib_name) else 0 candidate = (name_match, n, newest.name) cur = best.get(owner) # higher name_match, then higher count, then (already alphabetical @@ -140,13 +320,13 @@ def _type_index(root: Path) -> dict[str, str]: if cur is None or (candidate[0], candidate[1]) > (cur[0], cur[1]): best[owner] = candidate types = {owner: entry for owner, (_, _, entry) in best.items()} - _write_symbol_cache(idx_file, {"entry_count": len(entries), "types": types}) + _write_symbol_cache(idx_file, {"index_key": key, "types": types}) return types @router.get("/type/{name}") def get_type_members(name: str) -> JSONResponse: - """Members of one class/struct, whichever cached library declares it. + """Members of one class/struct, whichever library declares it. This is the long-tail path that is safe for completion quality: the engine calls it only after tree-sitter has resolved a receiver to a @@ -156,18 +336,24 @@ def get_type_members(name: str) -> JSONResponse: which are what made feeding whole library sets into the candidate pool measurably worse. Payload shape matches /symbols/ so the engine's RemoteSymbolCache can store it unchanged.""" - root = _cache_root() - if not root.is_dir(): - raise HTTPException(status_code=404, detail=f"library cache not available at {root}") + sources = _sources() + if not sources: + raise HTTPException(status_code=404, detail="no library source available") if not re.fullmatch(r"[A-Za-z_]\w{0,80}", name): raise HTTPException(status_code=400, detail="not a type name") - entry_name = _type_index(root).get(name) - if entry_name is None: - raise HTTPException(status_code=404, detail=f"no cached library declares {name}") - entry = root / entry_name - if not entry.is_dir(): - raise HTTPException(status_code=404, detail=f"stale type index for {name}") - payload = _payload_for(root, entry) + + for src in sources: + types = _type_index(src) + entry_name = types.get(name) + if entry_name is None: + continue + entry = src.root / entry_name + if not entry.is_dir(): + continue # stale index for this source; try the next + payload = _payload_for(src, entry) + break + else: + raise HTTPException(status_code=404, detail=f"no installed library declares {name}") # Own members first, then the inheritance chain, breadth-first, each base # resolved through the same index (it may live in another library: @@ -180,7 +366,6 @@ def get_type_members(name: str) -> JSONResponse: chain: list[str] = [] queue = [(name, payload)] visited = {name} - types = _type_index(root) while queue and len(chain) < _MAX_BASES: cur, cur_payload = queue.pop(0) if cur != name: @@ -203,17 +388,17 @@ def get_type_members(name: str) -> JSONResponse: continue visited.add(base) base_entry = types.get(base) - if base_entry and (root / base_entry).is_dir(): - queue.append((base, _payload_for(root, root / base_entry))) + if base_entry and (src.root / base_entry).is_dir(): + queue.append((base, _payload_for(src, src.root / base_entry))) else: - # Not in the cache (a core class like Print/Stream, or an + # Not in this source (a core class like Print/Stream, or an # embedded one like Adafruit_GFX): still report it so the # host can supply the members itself. chain.append(base) return JSONResponse( content={ - "id": payload.get("id") or entry_name, + "id": payload.get("id") or entry.name, "triggers": payload.get("triggers", []), "type": name, "bases": chain, @@ -226,15 +411,15 @@ def get_type_members(name: str) -> JSONResponse: @router.get("/symbols/{spec}") def get_symbols(spec: str) -> JSONResponse: """Symbol table for one library. `spec` is `Name@Version` or a bare - `Name` (which resolves to the newest cached version). Names and versions + `Name` (which resolves to the newest version present). Names and versions are normalized with the same rules the cache uses, so `Adafruit GFX Library@1.12.6` and `adafruitgfxlibrary@1.12.6` hit the same entry.""" name, version = parse_spec(spec) - root = _cache_root() - if not root.is_dir(): - raise HTTPException(status_code=404, detail=f"library cache not available at {root}") - entry = find_cache_entry(root, name, version) - if entry is None: - raise HTTPException(status_code=404, detail=f"library not found in cache: {spec}") - - return JSONResponse(content=_payload_for(root, entry), headers=_CACHE_HEADERS) + sources = _sources() + if not sources: + raise HTTPException(status_code=404, detail="no library source available") + for src in sources: + entry = _find_entry(src, name, version) + if entry is not None: + return JSONResponse(content=_payload_for(src, entry), headers=_CACHE_HEADERS) + raise HTTPException(status_code=404, detail=f"library not installed: {spec}") diff --git a/test/backend/unit/test_intellisense_local_libraries.py b/test/backend/unit/test_intellisense_local_libraries.py new file mode 100644 index 00000000..4e159bb5 --- /dev/null +++ b/test/backend/unit/test_intellisense_local_libraries.py @@ -0,0 +1,181 @@ +"""The symbol server answers from a plain arduino-cli sketchbook too. + +velxio.dev keeps its libraries in a content-addressed cache, where an entry +dir name IS the content hash. Velxio Desktop's sidecar has no such cache: its +libraries are whatever `arduino-cli lib install` put in the sketchbook, under +plain folder names, and they get rewritten in place on upgrade. Without this +path the desktop editor gets the engine's embedded catalog and nothing else — +`client.` on a library the user installed himself resolves to nothing. + +The routes are called directly (no TestClient) so the suite keeps needing +nothing but pytest. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +_REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO / "backend")) + +from app.api.routes import intellisense # noqa: E402 + +HEADER = """ +class Widget : public Print { + public: + Widget(int pin); + void begin(void); + bool ready(); + private: + int _pin; +}; +""" + + +def _lib(libraries: Path, folder: str, name: str, version: str, header: str = HEADER) -> Path: + d = libraries / folder + (d / "src").mkdir(parents=True, exist_ok=True) + (d / "library.properties").write_text( + f"name={name}\nversion={version}\narchitectures=*\n", encoding="utf-8" + ) + (d / "src" / f"{folder}.h").write_text(header, encoding="utf-8") + return d + + +@pytest.fixture +def sketchbook(tmp_path, monkeypatch): + """A sketchbook with no content-addressed cache in sight. Both env vars are + set so the resolver never reaches arduino-cli or the home-dir guesses — + a unit test must not depend on what is installed on the machine.""" + libraries = tmp_path / "sketchbook" / "libraries" + libraries.mkdir(parents=True) + monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(tmp_path / "no-cache-here")) + monkeypatch.setenv("VELXIO_SYMBOL_LIBRARIES_DIR", str(libraries)) + return libraries + + +def _body(response) -> dict: + return json.loads(response.body) + + +def test_a_sketchbook_library_answers_by_display_name(sketchbook): + _lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0") + + payload = _body(intellisense.get_symbols("My Widget Library")) + + assert payload["id"] == "My Widget Library" + assert "MyWidget.h" in payload["triggers"] + assert {"begin", "ready"} <= {s["name"] for s in payload["symbols"]} + + +def test_the_installed_version_answers_a_pin_for_another_one(sketchbook): + """The project pins what velxio.dev cached; the desktop compiles against + what is on disk. Answering with the installed version beats a 404.""" + _lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0") + + exact = _body(intellisense.get_symbols("My Widget Library@1.4.0")) + other = _body(intellisense.get_symbols("My Widget Library@9.9.9")) + + assert exact["id"] == other["id"] == "My Widget Library" + + +def test_an_uninstalled_library_is_404(sketchbook): + _lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0") + + with pytest.raises(HTTPException) as err: + intellisense.get_symbols("Nothing Like This") + assert err.value.status_code == 404 + + +def test_type_lookup_resolves_a_class_from_the_sketchbook(sketchbook): + _lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0") + + payload = _body(intellisense.get_type_members("Widget")) + + assert payload["type"] == "Widget" + assert {"begin", "ready"} <= {s["name"] for s in payload["symbols"]} + # Print is not an installed library — reported as an unresolved base so the + # editor can supply its own members for it. + assert "Print" in payload["bases"] + + +def test_scans_are_cached_beside_libraries_not_inside_them(sketchbook): + """arduino-cli scans `libraries/` for libraries. Nothing we write may land + there, or a stray dir shows up in `lib list`.""" + _lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0") + + intellisense.get_symbols("My Widget Library") + + assert (sketchbook.parent / ".velxio-symbols").is_dir() + assert [d.name for d in sketchbook.iterdir()] == ["MyWidget"] + + +def test_upgrading_a_library_in_place_invalidates_its_cached_scan(sketchbook): + """A sketchbook folder keeps its name across `lib upgrade`, so the scan + cannot be keyed by name — it is keyed by a content stamp.""" + lib = _lib(sketchbook, "MyWidget", "My Widget Library", "1.4.0") + first = _body(intellisense.get_symbols("My Widget Library")) + assert "shutdown" not in {s["name"] for s in first["symbols"]} + + (lib / "src" / "MyWidget.h").write_text( + HEADER.replace("bool ready();", "bool ready();\n void shutdown();"), encoding="utf-8" + ) + (lib / "library.properties").write_text( + "name=My Widget Library\nversion=1.5.0\narchitectures=*\n", encoding="utf-8" + ) + + second = _body(intellisense.get_symbols("My Widget Library")) + assert "shutdown" in {s["name"] for s in second["symbols"]} + + +def test_the_content_addressed_cache_wins_over_the_sketchbook(tmp_path, monkeypatch): + """On velxio.dev both exist (the pro overlay points arduino-cli's + sketchbook at the cache). The immutable one answers.""" + cache = tmp_path / "libcache" + cache.mkdir() + _lib(cache, "mywidgetlibrary@2.0.0-abcdef123456", "My Widget Library", "2.0.0") + libraries = tmp_path / "sketchbook" / "libraries" + libraries.mkdir(parents=True) + _lib(libraries, "MyWidget", "My Widget Library", "1.4.0") + monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(cache)) + monkeypatch.setenv("VELXIO_SYMBOL_LIBRARIES_DIR", str(libraries)) + + payload = _body(intellisense.get_symbols("My Widget Library")) + + # The cache entry's scan is keyed by its dir name; the sketchbook's is not. + assert (cache / ".symbols" / "mywidgetlibrary@2.0.0-abcdef123456.json").is_file() + assert not (libraries.parent / ".velxio-symbols").exists() + assert payload["id"] == "My Widget Library" + + +def test_a_sketchbook_that_resolves_to_the_cache_is_not_scanned_twice(tmp_path, monkeypatch): + """The pro overlay's fallback sketchbook is a symlink to the cache root.""" + cache = tmp_path / "libcache" + cache.mkdir() + _lib(cache, "mywidgetlibrary@2.0.0-abcdef123456", "My Widget Library", "2.0.0") + sketchbook = tmp_path / "cache-sketchbook" + sketchbook.mkdir() + (sketchbook / "libraries").symlink_to(cache, target_is_directory=True) + monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(cache)) + monkeypatch.delenv("VELXIO_SYMBOL_LIBRARIES_DIR", raising=False) + monkeypatch.setenv("VELXIO_FALLBACK_SKETCHBOOK", str(sketchbook)) + + assert [s.content_addressed for s in intellisense._sources()] == [True] + + +def test_no_library_source_at_all_is_404(tmp_path, monkeypatch): + monkeypatch.setenv("VELXIO_LIBCACHE_DIR", str(tmp_path / "nope")) + monkeypatch.setenv("VELXIO_SYMBOL_LIBRARIES_DIR", str(tmp_path / "also-nope")) + monkeypatch.delenv("ARDUINO_DIRECTORIES_USER", raising=False) + monkeypatch.delenv("VELXIO_FALLBACK_SKETCHBOOK", raising=False) + # Never probe arduino-cli or the home dir from a unit test. + monkeypatch.setattr(intellisense, "_cli_sketchbook", lambda: None) + monkeypatch.setattr(intellisense.Path, "home", staticmethod(lambda: tmp_path / "empty-home")) + + with pytest.raises(HTTPException) as err: + intellisense.get_symbols("Anything") + assert err.value.status_code == 404