Skip to main content

normalize_languages/
external_packages.rs

1//! External package resolution - shared types only.
2//!
3//! Language-specific resolution has been moved to individual language modules:
4//! - Python: python.rs
5//! - Go: go.rs
6//! - Rust: rust.rs
7//! - JavaScript/TypeScript/Deno: ecmascript.rs
8//! - Java: java.rs
9//! - C/C++: moved to normalize-local-deps
10//!
11//! This module contains:
12//! - ResolvedPackage: Common result type for package resolution
13//! - Global cache: ~/.cache/normalize/ for indexed packages
14//! - PackageIndex: SQLite-backed package/symbol index
15
16use std::path::PathBuf;
17
18// =============================================================================
19// Shared Types
20// =============================================================================
21
22/// Result of resolving an external package
23#[derive(Debug, Clone)]
24pub struct ResolvedPackage {
25    /// Path to the package source
26    pub path: PathBuf,
27    /// Package name as imported
28    pub name: String,
29    /// Whether this is a namespace package (no __init__.py)
30    pub is_namespace: bool,
31}
32
33// =============================================================================
34// Global Cache
35// =============================================================================
36
37/// Get the global normalize cache directory (~/.cache/normalize/).
38pub fn get_global_cache_dir() -> Option<PathBuf> {
39    let cache_base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
40        PathBuf::from(xdg)
41    } else if let Ok(home) = std::env::var("HOME") {
42        PathBuf::from(home).join(".cache")
43    } else if let Ok(home) = std::env::var("USERPROFILE") {
44        PathBuf::from(home).join(".cache")
45    } else {
46        return None;
47    };
48
49    let normalize_cache = cache_base.join("normalize");
50    if !normalize_cache.exists() {
51        std::fs::create_dir_all(&normalize_cache).ok()?;
52    }
53
54    Some(normalize_cache)
55}
56
57/// Get the path to the unified global package index database.
58pub fn get_global_packages_db() -> Option<PathBuf> {
59    let cache = get_global_cache_dir()?;
60    Some(cache.join("packages.db"))
61}
62
63// =============================================================================
64// Global Package Index Database
65// =============================================================================
66
67use libsql::{Connection, Database, params};
68
69/// Error type for PackageIndex open operations.
70#[derive(Debug, thiserror::Error)]
71pub enum PackageIndexError {
72    #[error("failed to open package index: {0}")]
73    Open(#[from] libsql::Error),
74}
75
76/// Parsed version as (major, minor).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct Version {
79    pub major: u32,
80    pub minor: u32,
81}
82
83impl Version {
84    /// Parse a version string like `"1.2"` or `"1.2.3"` into a `Version`.
85    ///
86    /// **Note:** Only the major and minor components are stored; the patch
87    /// component (and any pre-release/build metadata) is silently discarded.
88    /// `Version::parse("1.2.3")` returns `Version { major: 1, minor: 2 }`.
89    pub fn parse(s: &str) -> Option<Version> {
90        let parts: Vec<&str> = s.split('.').collect();
91        if parts.len() >= 2 {
92            Some(Version {
93                major: parts[0].parse().ok()?,
94                minor: parts[1].parse().ok()?,
95            })
96        } else {
97            None
98        }
99    }
100
101    pub fn in_range(&self, min: Version, max: Option<Version>) -> bool {
102        if *self < min {
103            return false;
104        }
105        if let Some(max) = max
106            && *self > max
107        {
108            return false;
109        }
110        true
111    }
112}
113
114impl PartialOrd for Version {
115    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
116        Some(self.cmp(other))
117    }
118}
119
120impl Ord for Version {
121    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
122        match self.major.cmp(&other.major) {
123            std::cmp::Ordering::Equal => self.minor.cmp(&other.minor),
124            ord => ord,
125        }
126    }
127}
128
129/// A package record in the index.
130#[derive(Debug, Clone)]
131pub struct PackageRecord {
132    pub id: i64,
133    pub language: String,
134    pub name: String,
135    pub path: String,
136    pub min_major: u32,
137    pub min_minor: u32,
138    pub max_major: Option<u32>,
139    pub max_minor: Option<u32>,
140}
141
142impl PackageRecord {
143    pub fn min_version(&self) -> Version {
144        Version {
145            major: self.min_major,
146            minor: self.min_minor,
147        }
148    }
149
150    pub fn max_version(&self) -> Option<Version> {
151        match (self.max_major, self.max_minor) {
152            (Some(major), Some(minor)) => Some(Version { major, minor }),
153            _ => None,
154        }
155    }
156}
157
158/// A symbol record in the index.
159#[derive(Debug, Clone)]
160pub struct SymbolRecord {
161    pub id: i64,
162    pub package_id: i64,
163    pub name: String,
164    pub kind: String,
165    pub signature: String,
166    pub line: u32,
167}
168
169/// Global package index backed by libSQL.
170pub struct PackageIndex {
171    conn: Connection,
172    #[allow(dead_code)]
173    db: Database,
174}
175
176impl PackageIndex {
177    pub async fn open() -> Result<Self, PackageIndexError> {
178        let db_path = get_global_packages_db().ok_or_else(|| {
179            libsql::Error::SqliteFailure(1, "Cannot determine cache directory".into())
180        })?;
181
182        let db = libsql::Builder::new_local(db_path).build().await?;
183        let conn = db.connect()?;
184        let index = PackageIndex { conn, db };
185        index.init_schema().await?;
186        Ok(index)
187    }
188
189    pub async fn open_in_memory() -> Result<Self, PackageIndexError> {
190        let db = libsql::Builder::new_local(":memory:").build().await?;
191        let conn = db.connect()?;
192        let index = PackageIndex { conn, db };
193        index.init_schema().await?;
194        Ok(index)
195    }
196
197    async fn init_schema(&self) -> Result<(), libsql::Error> {
198        self.conn
199            .execute(
200                "CREATE TABLE IF NOT EXISTS packages (
201                id INTEGER PRIMARY KEY,
202                language TEXT NOT NULL,
203                name TEXT NOT NULL,
204                path TEXT NOT NULL,
205                min_major INTEGER NOT NULL,
206                min_minor INTEGER NOT NULL,
207                max_major INTEGER,
208                max_minor INTEGER,
209                indexed_at INTEGER NOT NULL
210            )",
211                (),
212            )
213            .await?;
214
215        self.conn
216            .execute(
217                "CREATE INDEX IF NOT EXISTS idx_packages_lang_name ON packages(language, name)",
218                (),
219            )
220            .await?;
221
222        self.conn
223            .execute(
224                "CREATE TABLE IF NOT EXISTS symbols (
225                id INTEGER PRIMARY KEY,
226                package_id INTEGER NOT NULL,
227                name TEXT NOT NULL,
228                kind TEXT NOT NULL,
229                signature TEXT NOT NULL,
230                line INTEGER NOT NULL,
231                FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
232            )",
233                (),
234            )
235            .await?;
236
237        self.conn
238            .execute(
239                "CREATE INDEX IF NOT EXISTS idx_symbols_package ON symbols(package_id)",
240                (),
241            )
242            .await?;
243        self.conn
244            .execute(
245                "CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)",
246                (),
247            )
248            .await?;
249
250        Ok(())
251    }
252
253    pub async fn insert_package(
254        &self,
255        language: &str,
256        name: &str,
257        path: &str,
258        min_version: Version,
259        max_version: Option<Version>,
260    ) -> Result<i64, libsql::Error> {
261        let now = std::time::SystemTime::now()
262            .duration_since(std::time::UNIX_EPOCH)
263            .unwrap_or_default()
264            .as_secs() as i64;
265
266        self.conn.execute(
267            "INSERT INTO packages (language, name, path, min_major, min_minor, max_major, max_minor, indexed_at)
268             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
269            params![
270                language,
271                name,
272                path,
273                min_version.major,
274                min_version.minor,
275                max_version.map(|v| v.major),
276                max_version.map(|v| v.minor),
277                now,
278            ],
279        ).await?;
280        Ok(self.conn.last_insert_rowid())
281    }
282
283    pub async fn insert_symbol(
284        &self,
285        package_id: i64,
286        name: &str,
287        kind: &str,
288        signature: &str,
289        line: u32,
290    ) -> Result<i64, libsql::Error> {
291        self.conn
292            .execute(
293                "INSERT INTO symbols (package_id, name, kind, signature, line)
294             VALUES (?1, ?2, ?3, ?4, ?5)",
295                params![package_id, name, kind, signature, line],
296            )
297            .await?;
298        Ok(self.conn.last_insert_rowid())
299    }
300
301    pub async fn find_package(
302        &self,
303        language: &str,
304        name: &str,
305        version: Option<Version>,
306    ) -> Result<Option<PackageRecord>, libsql::Error> {
307        let mut rows = self
308            .conn
309            .query(
310                "SELECT id, language, name, path, min_major, min_minor, max_major, max_minor
311             FROM packages WHERE language = ?1 AND name = ?2",
312                params![language, name],
313            )
314            .await?;
315
316        let mut packages = Vec::new();
317        while let Some(row) = rows.next().await? {
318            packages.push(PackageRecord {
319                id: row.get(0)?,
320                language: row.get(1)?,
321                name: row.get(2)?,
322                path: row.get(3)?,
323                min_major: row.get(4)?,
324                min_minor: row.get(5)?,
325                max_major: row.get(6)?,
326                max_minor: row.get(7)?,
327            });
328        }
329
330        if let Some(ver) = version {
331            for pkg in packages {
332                if ver.in_range(pkg.min_version(), pkg.max_version()) {
333                    return Ok(Some(pkg));
334                }
335            }
336            Ok(None)
337        } else {
338            Ok(packages.into_iter().next())
339        }
340    }
341
342    pub async fn get_symbols(&self, package_id: i64) -> Result<Vec<SymbolRecord>, libsql::Error> {
343        let mut rows = self
344            .conn
345            .query(
346                "SELECT id, package_id, name, kind, signature, line
347             FROM symbols WHERE package_id = ?1 ORDER BY line",
348                params![package_id],
349            )
350            .await?;
351
352        let mut symbols = Vec::new();
353        while let Some(row) = rows.next().await? {
354            symbols.push(SymbolRecord {
355                id: row.get(0)?,
356                package_id: row.get(1)?,
357                name: row.get(2)?,
358                kind: row.get(3)?,
359                signature: row.get(4)?,
360                line: row.get(5)?,
361            });
362        }
363
364        Ok(symbols)
365    }
366
367    pub async fn find_symbol(
368        &self,
369        language: &str,
370        symbol_name: &str,
371        version: Option<Version>,
372    ) -> Result<Vec<(PackageRecord, SymbolRecord)>, libsql::Error> {
373        let mut rows = self.conn.query(
374            "SELECT p.id, p.language, p.name, p.path, p.min_major, p.min_minor, p.max_major, p.max_minor,
375                    s.id, s.package_id, s.name, s.kind, s.signature, s.line
376             FROM symbols s
377             JOIN packages p ON s.package_id = p.id
378             WHERE p.language = ?1 AND s.name = ?2",
379            params![language, symbol_name],
380        ).await?;
381
382        let mut results = Vec::new();
383        while let Some(row) = rows.next().await? {
384            results.push((
385                PackageRecord {
386                    id: row.get(0)?,
387                    language: row.get(1)?,
388                    name: row.get(2)?,
389                    path: row.get(3)?,
390                    min_major: row.get(4)?,
391                    min_minor: row.get(5)?,
392                    max_major: row.get(6)?,
393                    max_minor: row.get(7)?,
394                },
395                SymbolRecord {
396                    id: row.get(8)?,
397                    package_id: row.get(9)?,
398                    name: row.get(10)?,
399                    kind: row.get(11)?,
400                    signature: row.get(12)?,
401                    line: row.get(13)?,
402                },
403            ));
404        }
405
406        if let Some(ver) = version {
407            Ok(results
408                .into_iter()
409                .filter(|(pkg, _)| ver.in_range(pkg.min_version(), pkg.max_version()))
410                .collect())
411        } else {
412            Ok(results)
413        }
414    }
415
416    pub async fn is_indexed(&self, language: &str, name: &str) -> Result<bool, libsql::Error> {
417        let mut rows = self
418            .conn
419            .query(
420                "SELECT COUNT(*) FROM packages WHERE language = ?1 AND name = ?2",
421                params![language, name],
422            )
423            .await?;
424
425        if let Some(row) = rows.next().await? {
426            let count: i64 = row.get(0)?;
427            Ok(count > 0)
428        } else {
429            Ok(false)
430        }
431    }
432
433    pub async fn delete_package(&self, package_id: i64) -> Result<(), libsql::Error> {
434        self.conn
435            .execute(
436                "DELETE FROM symbols WHERE package_id = ?1",
437                params![package_id],
438            )
439            .await?;
440        self.conn
441            .execute("DELETE FROM packages WHERE id = ?1", params![package_id])
442            .await?;
443        Ok(())
444    }
445
446    pub async fn clear(&self) -> Result<(), libsql::Error> {
447        self.conn.execute("DELETE FROM symbols", ()).await?;
448        self.conn.execute("DELETE FROM packages", ()).await?;
449        Ok(())
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_version_parsing() {
459        assert_eq!(
460            Version::parse("3.11"),
461            Some(Version {
462                major: 3,
463                minor: 11
464            })
465        );
466        assert_eq!(
467            Version::parse("1.21"),
468            Some(Version {
469                major: 1,
470                minor: 21
471            })
472        );
473        assert_eq!(Version::parse("invalid"), None);
474    }
475
476    #[test]
477    fn test_version_comparison() {
478        let v1 = Version {
479            major: 3,
480            minor: 10,
481        };
482        let v2 = Version {
483            major: 3,
484            minor: 11,
485        };
486        let v3 = Version { major: 4, minor: 0 };
487
488        assert!(v1 < v2);
489        assert!(v2 < v3);
490        assert!(v1.in_range(v1, Some(v2)));
491        assert!(!v3.in_range(v1, Some(v2)));
492    }
493
494    #[tokio::test]
495    async fn test_package_index() {
496        let index = PackageIndex::open_in_memory().await.unwrap();
497
498        // Insert a package
499        let pkg_id = index
500            .insert_package(
501                "python",
502                "requests",
503                "/path/to/requests",
504                Version { major: 3, minor: 8 },
505                None,
506            )
507            .await
508            .unwrap();
509
510        // Insert a symbol
511        index
512            .insert_symbol(pkg_id, "get", "function", "def get(url)", 10)
513            .await
514            .unwrap();
515
516        // Find the package
517        let found = index
518            .find_package("python", "requests", None)
519            .await
520            .unwrap();
521        assert!(found.is_some());
522        assert_eq!(found.unwrap().name, "requests");
523
524        // Find the symbol
525        let symbols = index.get_symbols(pkg_id).await.unwrap();
526        assert_eq!(symbols.len(), 1);
527        assert_eq!(symbols[0].name, "get");
528
529        // Check indexed
530        assert!(index.is_indexed("python", "requests").await.unwrap());
531        assert!(!index.is_indexed("python", "nonexistent").await.unwrap());
532    }
533}