Skip to main content

graphix_package/
lib.rs

1use anyhow::{anyhow, bail, Context, Result};
2use arcstr::ArcStr;
3use async_trait::async_trait;
4use chrono::Local;
5use compact_str::{format_compact, CompactString};
6use crates_io_api::AsyncClient;
7use flate2::bufread::MultiGzDecoder;
8use fxhash::FxHashMap;
9use graphix_compiler::{env::Env, expr::ExprId, ExecCtx};
10use graphix_rt::{CompExp, GXExt, GXHandle, GXRt};
11use handlebars::Handlebars;
12pub use indexmap::IndexSet;
13use netidx_value::Value;
14use serde_json::json;
15use std::{
16    any::Any,
17    collections::BTreeMap,
18    path::{Path, PathBuf},
19    process::Stdio,
20    time::Duration,
21};
22use tokio::{
23    fs,
24    io::{AsyncBufReadExt, BufReader},
25    process::Command,
26    sync::oneshot,
27    task,
28};
29use walkdir::WalkDir;
30
31#[cfg(test)]
32mod test;
33
34/// Trait implemented by custom Graphix displays, e.g. TUIs, GUIs, etc.
35#[async_trait]
36pub trait CustomDisplay<X: GXExt>: Any {
37    /// Clear the custom display, freeing any used resources.
38    ///
39    /// This is called when the shell user has indicated that they
40    /// want to return to the normal display mode or when the stop
41    /// channel has been triggered by this custom display.
42    async fn clear(&mut self);
43
44    /// Process an update from the Graphix rt in the context of the
45    /// custom display.
46    ///
47    /// This will be called by every update, even if it isn't related
48    /// to the custom display. If the future returned by this method
49    /// is never determined then the shell will hang.
50    async fn process_update(&mut self, env: &Env, id: ExprId, v: Value);
51}
52
53/// Trait implemented by Graphix packages
54pub trait Package<X: GXExt> {
55    /// register builtins and return a resolver containing Graphix
56    /// code contained in the package.
57    ///
58    /// Graphix modules must be registered by path in the modules table
59    /// and the package must be registered by name in the root_mods set.
60    /// Normally this is handled by the defpackage macro.
61    fn register(
62        ctx: &mut ExecCtx<GXRt<X>, X::UserEvent>,
63        modules: &mut FxHashMap<netidx_core::path::Path, ArcStr>,
64        root_mods: &mut IndexSet<ArcStr>,
65    ) -> Result<()>;
66
67    /// Return true if the `CompExp` matches the custom display type
68    /// of this package.
69    fn is_custom(gx: &GXHandle<X>, env: &Env, e: &CompExp<X>) -> bool;
70
71    /// Build and return a `CustomDisplay` instance which will be used
72    /// to display the `CompExp` `e`.
73    ///
74    /// If the custom display mode wishes to stop (for example the
75    /// user closed the last gui window), then the stop channel should
76    /// be triggered, and the shell will call `CustomDisplay::clear`
77    /// before dropping the `CustomDisplay`.
78    fn init_custom(
79        gx: &GXHandle<X>,
80        env: &Env,
81        stop: oneshot::Sender<()>,
82        e: CompExp<X>,
83    ) -> Result<Box<dyn CustomDisplay<X>>>;
84
85    /// Return the main program source if this package has one and the
86    /// `standalone` feature is enabled.
87    fn main_program() -> Option<&'static str>;
88}
89
90// package skeleton, our version, and deps template
91struct Skel {
92    version: &'static str,
93    cargo_toml: &'static str,
94    deps_rs: &'static str,
95    lib_rs: &'static str,
96    mod_gx: &'static str,
97    mod_gxi: &'static str,
98    readme_md: &'static str,
99}
100
101static SKEL: Skel = Skel {
102    version: env!("CARGO_PKG_VERSION"),
103    cargo_toml: include_str!("skel/Cargo.toml.hbs"),
104    deps_rs: include_str!("skel/deps.rs"),
105    lib_rs: include_str!("skel/lib.rs"),
106    mod_gx: include_str!("skel/mod.gx"),
107    mod_gxi: include_str!("skel/mod.gxi"),
108    readme_md: include_str!("skel/README.md"),
109};
110
111/// Create a new graphix package
112///
113/// The package will be created in a new directory named
114/// `graphix-package-{name}` inside the directory `base`. If base is not a
115/// directory the function will fail.
116pub async fn create_package(base: &Path, name: &str) -> Result<()> {
117    if !fs::metadata(base).await?.is_dir() {
118        bail!("base path {base:?} does not exist, or is not a directory")
119    }
120    if name.contains(|c: char| c != '-' && !c.is_ascii_alphanumeric())
121        || !name.starts_with("graphix-package-")
122    {
123        bail!("invalid package name, name must match graphix-package-[-a-z]+")
124    }
125    let full_path = base.join(name);
126    if fs::metadata(&full_path).await.is_ok() {
127        bail!("package {name} already exists")
128    }
129    fs::create_dir_all(&full_path.join("src").join("graphix")).await?;
130    let mut hb = Handlebars::new();
131    hb.register_template_string("Cargo.toml", SKEL.cargo_toml)?;
132    hb.register_template_string("lib.rs", SKEL.lib_rs)?;
133    hb.register_template_string("mod.gx", SKEL.mod_gx)?;
134    hb.register_template_string("mod.gxi", SKEL.mod_gxi)?;
135    hb.register_template_string("README.md", SKEL.readme_md)?;
136    let name = name.strip_prefix("graphix-package-").unwrap();
137    let params = json!({"name": name, "deps": []});
138    fs::write(full_path.join("Cargo.toml"), hb.render("Cargo.toml", &params)?).await?;
139    fs::write(full_path.join("README.md"), hb.render("README.md", &params)?).await?;
140    let src = full_path.join("src");
141    fs::write(src.join("lib.rs"), hb.render("lib.rs", &params)?).await?;
142    let graphix_src = src.join("graphix");
143    fs::write(&graphix_src.join("mod.gx"), hb.render("mod.gx", &params)?).await?;
144    fs::write(&graphix_src.join("mod.gxi"), hb.render("mod.gxi", &params)?).await?;
145    Ok(())
146}
147
148fn graphix_data_dir() -> Result<PathBuf> {
149    Ok(dirs::data_local_dir()
150        .ok_or_else(|| anyhow!("can't find your data dir"))?
151        .join("graphix"))
152}
153
154fn packages_toml_path() -> Result<PathBuf> {
155    Ok(graphix_data_dir()?.join("packages.toml"))
156}
157
158/// The default set of packages shipped with graphix
159const DEFAULT_PACKAGES: &[(&str, &str)] = &[
160    ("core", SKEL.version),
161    ("array", SKEL.version),
162    ("str", SKEL.version),
163    ("map", SKEL.version),
164    ("fs", SKEL.version),
165    ("time", SKEL.version),
166    ("net", SKEL.version),
167    ("re", SKEL.version),
168    ("rand", SKEL.version),
169    ("tui", SKEL.version),
170];
171
172fn is_stdlib_package(name: &str) -> bool {
173    DEFAULT_PACKAGES.iter().any(|(n, _)| *n == name)
174}
175
176/// A package entry in packages.toml — either a version string or a path.
177#[derive(Debug, Clone)]
178pub enum PackageEntry {
179    Version(String),
180    Path(PathBuf),
181}
182
183impl std::fmt::Display for PackageEntry {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        match self {
186            Self::Version(v) => write!(f, "{v}"),
187            Self::Path(p) => write!(f, "path:{}", p.display()),
188        }
189    }
190}
191
192/// Read the packages.toml file, creating it with defaults if it doesn't exist.
193async fn read_packages() -> Result<BTreeMap<String, PackageEntry>> {
194    let path = packages_toml_path()?;
195    match fs::read_to_string(&path).await {
196        Ok(contents) => {
197            let doc: toml::Value =
198                toml::from_str(&contents).context("parsing packages.toml")?;
199            let tbl = doc
200                .get("packages")
201                .and_then(|v| v.as_table())
202                .ok_or_else(|| anyhow!("packages.toml missing [packages] table"))?;
203            let mut packages = BTreeMap::new();
204            for (k, v) in tbl {
205                let entry = match v {
206                    toml::Value::String(s) => PackageEntry::Version(s.clone()),
207                    toml::Value::Table(t) => {
208                        if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
209                            PackageEntry::Path(PathBuf::from(p))
210                        } else {
211                            bail!("package {k}: table entry must have a 'path' key")
212                        }
213                    }
214                    _ => bail!("package {k}: expected a version string or table"),
215                };
216                packages.insert(k.clone(), entry);
217            }
218            Ok(packages)
219        }
220        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
221            let packages: BTreeMap<String, PackageEntry> = DEFAULT_PACKAGES
222                .iter()
223                .map(|(k, v)| (k.to_string(), PackageEntry::Version(v.to_string())))
224                .collect();
225            write_packages(&packages).await?;
226            Ok(packages)
227        }
228        Err(e) => Err(e.into()),
229    }
230}
231
232/// Write the packages.toml file
233async fn write_packages(packages: &BTreeMap<String, PackageEntry>) -> Result<()> {
234    let path = packages_toml_path()?;
235    if let Some(parent) = path.parent() {
236        fs::create_dir_all(parent).await?;
237    }
238    let mut doc = toml::value::Table::new();
239    let mut tbl = toml::value::Table::new();
240    for (k, entry) in packages {
241        match entry {
242            PackageEntry::Version(v) => {
243                tbl.insert(k.clone(), toml::Value::String(v.clone()));
244            }
245            PackageEntry::Path(p) => {
246                let mut t = toml::value::Table::new();
247                t.insert(
248                    "path".to_string(),
249                    toml::Value::String(p.to_string_lossy().into_owned()),
250                );
251                tbl.insert(k.clone(), toml::Value::Table(t));
252            }
253        }
254    }
255    doc.insert("packages".to_string(), toml::Value::Table(tbl));
256    fs::write(&path, toml::to_string_pretty(&doc)?).await?;
257    Ok(())
258}
259
260/// Get the graphix version string from the running binary
261async fn graphix_version() -> Result<String> {
262    let graphix = which::which("graphix").context("can't find the graphix command")?;
263    let c = Command::new(&graphix).arg("--version").stdout(Stdio::piped()).spawn()?;
264    let line = BufReader::new(c.stdout.unwrap())
265        .lines()
266        .next_line()
267        .await?
268        .ok_or_else(|| anyhow!("graphix did not return a version"))?;
269    // version output may be "graphix 0.3.2" or just "0.3.2"
270    Ok(line.split_whitespace().last().unwrap_or(&line).to_string())
271}
272
273// fetch our source from the local cargo cache (preferred method)
274async fn extract_local_source(cargo: &Path, version: &str) -> Result<PathBuf> {
275    let graphix_build_dir = graphix_data_dir()?.join("build");
276    let graphix_dir = graphix_build_dir.join(format!("graphix-shell-{version}"));
277    match fs::metadata(&graphix_build_dir).await {
278        Err(_) => fs::create_dir_all(&graphix_build_dir).await?,
279        Ok(md) if !md.is_dir() => bail!("{graphix_build_dir:?} isn't a directory"),
280        Ok(_) => (),
281    }
282    match fs::metadata(&graphix_dir).await {
283        Ok(md) if !md.is_dir() => bail!("{graphix_dir:?} isn't a directory"),
284        Ok(_) => return Ok(graphix_dir),
285        Err(_) => (),
286    }
287    let package = format!("graphix-shell-{version}");
288    let cargo_root = cargo
289        .parent()
290        .ok_or_else(|| anyhow!("can't find cargo root"))?
291        .parent()
292        .ok_or_else(|| anyhow!("can't find cargo root"))?;
293    let cargo_src = cargo_root.join("registry").join("src");
294    match fs::metadata(&cargo_src).await {
295        Ok(md) if md.is_dir() => (),
296        Err(_) | Ok(_) => bail!("can't find cargo cache {cargo_src:?}"),
297    };
298    let r = task::spawn_blocking({
299        let graphix_dir = graphix_dir.clone();
300        move || -> Result<()> {
301            let src_path = WalkDir::new(&cargo_src)
302                .max_depth(2)
303                .into_iter()
304                .find_map(|e| {
305                    let e = e.ok()?;
306                    if e.file_type().is_dir() && e.path().ends_with(&package) {
307                        return Some(e.into_path());
308                    }
309                    None
310                })
311                .ok_or_else(|| anyhow!("can't find {package} in {cargo_src:?}"))?;
312            cp_r::CopyOptions::new().copy_tree(&src_path, graphix_dir)?;
313            Ok(())
314        }
315    })
316    .await?;
317    match r {
318        Ok(()) => Ok(graphix_dir),
319        Err(e) => {
320            let _ = fs::remove_dir_all(&graphix_dir).await;
321            Err(e)
322        }
323    }
324}
325
326// download our src from crates.io (backup method)
327async fn download_source(crates_io: &AsyncClient, version: &str) -> Result<PathBuf> {
328    let package = format!("graphix-shell-{version}");
329    let graphix_build_dir = graphix_data_dir()?.join("build");
330    let graphix_dir = graphix_build_dir.join(&package);
331    match fs::metadata(&graphix_build_dir).await {
332        Err(_) => fs::create_dir_all(&graphix_build_dir).await?,
333        Ok(md) if !md.is_dir() => bail!("{graphix_build_dir:?} isn't a directory"),
334        Ok(_) => (),
335    }
336    match fs::metadata(&graphix_dir).await {
337        Ok(md) if !md.is_dir() => bail!("{graphix_dir:?} isn't a directory"),
338        Ok(_) => return Ok(graphix_dir),
339        Err(_) => (),
340    }
341    let cr = crates_io.get_crate("graphix-shell").await?;
342    let cr_version = cr
343        .versions
344        .into_iter()
345        .find(|v| v.num == version)
346        .ok_or_else(|| anyhow!("can't find version {version} on crates.io"))?;
347    let crate_data_tar_gz = reqwest::get(&cr_version.dl_path).await?.bytes().await?;
348    let r = task::spawn_blocking({
349        let graphix_dir = graphix_dir.clone();
350        move || -> Result<()> {
351            use std::io::Read;
352            let mut crate_data_tar = vec![];
353            MultiGzDecoder::new(&crate_data_tar_gz[..])
354                .read_to_end(&mut crate_data_tar)?;
355            std::fs::create_dir_all(&graphix_dir)?;
356            tar::Archive::new(&mut &crate_data_tar[..]).unpack(&graphix_dir)?;
357            Ok(())
358        }
359    })
360    .await?;
361    match r {
362        Ok(()) => Ok(graphix_dir),
363        Err(e) => {
364            let _ = fs::remove_dir_all(&graphix_dir).await;
365            Err(e)
366        }
367    }
368}
369
370#[derive(Debug, Clone)]
371pub struct PackageId {
372    name: CompactString,
373    version: Option<CompactString>,
374    path: Option<PathBuf>,
375}
376
377impl PackageId {
378    pub fn new(name: &str, version: Option<&str>) -> Self {
379        let name = if name.starts_with("graphix-package-") {
380            CompactString::from(name.strip_prefix("graphix-package-").unwrap())
381        } else {
382            CompactString::from(name)
383        };
384        let version = version.map(CompactString::from);
385        Self { name, version, path: None }
386    }
387
388    pub fn with_path(name: &str, path: PathBuf) -> Self {
389        let name = if name.starts_with("graphix-package-") {
390            CompactString::from(name.strip_prefix("graphix-package-").unwrap())
391        } else {
392            CompactString::from(name)
393        };
394        Self { name, version: None, path: Some(path) }
395    }
396
397    /// Short name without graphix-package- prefix
398    pub fn name(&self) -> &str {
399        &self.name
400    }
401
402    /// The full crate name
403    pub fn crate_name(&self) -> CompactString {
404        format_compact!("graphix-package-{}", self.name)
405    }
406
407    pub fn version(&self) -> Option<&str> {
408        self.version.as_ref().map(|s| s.as_str())
409    }
410
411    pub fn path(&self) -> Option<&Path> {
412        self.path.as_deref()
413    }
414}
415
416/// The Graphix package manager
417pub struct GraphixPM {
418    cratesio: AsyncClient,
419    cargo: PathBuf,
420}
421
422impl GraphixPM {
423    /// Create a new package manager
424    pub async fn new() -> Result<Self> {
425        let cargo = which::which("cargo").context("can't find the cargo command")?;
426        let cratesio = AsyncClient::new(
427            "Graphix Package Manager <eestokes@pm.me>",
428            Duration::from_secs(1),
429        )?;
430        Ok(Self { cratesio, cargo })
431    }
432
433    /// Open the lock file for the graphix data directory.
434    /// Call `.write()` on the returned lock to acquire exclusive access.
435    fn lock_file() -> Result<fd_lock::RwLock<std::fs::File>> {
436        let lock_path = graphix_data_dir()?.join("graphix.lock");
437        if let Some(parent) = lock_path.parent() {
438            std::fs::create_dir_all(parent)?;
439        }
440        let file = std::fs::OpenOptions::new()
441            .create(true)
442            .truncate(false)
443            .read(true)
444            .write(true)
445            .open(&lock_path)
446            .context("opening lock file")?;
447        Ok(fd_lock::RwLock::new(file))
448    }
449
450    /// Unpack a fresh copy of the graphix-shell source. Tries the
451    /// local cargo registry cache first, falls back to downloading
452    /// from crates.io.
453    async fn unpack_source(&self, version: &str) -> Result<PathBuf> {
454        match extract_local_source(&self.cargo, version).await {
455            Ok(p) => Ok(p),
456            Err(local) => match download_source(&self.cratesio, version).await {
457                Ok(p) => Ok(p),
458                Err(dl) => bail!("could not find our source local: {local}, dl: {dl}"),
459            },
460        }
461    }
462
463    /// Generate deps.rs from the package list
464    fn generate_deps_rs(
465        &self,
466        packages: &BTreeMap<String, PackageEntry>,
467    ) -> Result<String> {
468        let mut hb = Handlebars::new();
469        hb.register_template_string("deps.rs", SKEL.deps_rs)?;
470        let deps: Vec<serde_json::Value> = packages
471            .keys()
472            .map(|name| {
473                json!({
474                    "crate_name": format!("graphix_package_{}", name.replace('-', "_")),
475                })
476            })
477            .collect();
478        let params = json!({ "deps": deps });
479        Ok(hb.render("deps.rs", &params)?)
480    }
481
482    /// Update Cargo.toml to include package dependencies
483    fn update_cargo_toml(
484        &self,
485        cargo_toml_content: &str,
486        packages: &BTreeMap<String, PackageEntry>,
487    ) -> Result<String> {
488        use toml_edit::DocumentMut;
489        let mut doc: DocumentMut =
490            cargo_toml_content.parse().context("parsing Cargo.toml")?;
491        let deps = doc["dependencies"]
492            .as_table_mut()
493            .ok_or_else(|| anyhow!("Cargo.toml missing [dependencies]"))?;
494        let to_remove: Vec<String> = deps
495            .iter()
496            .filter_map(|(k, _)| {
497                if k.starts_with("graphix-package-") {
498                    Some(k.to_string())
499                } else {
500                    None
501                }
502            })
503            .collect();
504        for k in to_remove {
505            deps.remove(&k);
506        }
507        for (name, entry) in packages {
508            let crate_name = format!("graphix-package-{name}");
509            match entry {
510                PackageEntry::Version(version) => {
511                    deps[&crate_name] = toml_edit::value(version);
512                }
513                PackageEntry::Path(path) => {
514                    let mut tbl = toml_edit::InlineTable::new();
515                    tbl.insert(
516                        "path",
517                        toml_edit::Value::from(path.to_string_lossy().as_ref()),
518                    );
519                    deps[&crate_name] = toml_edit::Item::Value(tbl.into());
520                }
521            }
522        }
523        Ok(doc.to_string())
524    }
525
526    /// Rebuild the graphix binary with the given package set
527    async fn rebuild(
528        &self,
529        packages: &BTreeMap<String, PackageEntry>,
530        version: &str,
531    ) -> Result<()> {
532        println!("Unpacking graphix-shell source...");
533        // Delete existing build dir to get a fresh source
534        let build_dir = graphix_data_dir()?.join("build");
535        if fs::metadata(&build_dir).await.is_ok() {
536            fs::remove_dir_all(&build_dir).await?;
537        }
538        let source_dir = self.unpack_source(version).await?;
539        // Generate deps.rs
540        println!("Generating deps.rs...");
541        let deps_rs = self.generate_deps_rs(&packages)?;
542        fs::write(source_dir.join("src").join("deps.rs"), &deps_rs).await?;
543        // Update Cargo.toml with package dependencies
544        println!("Updating Cargo.toml...");
545        let cargo_toml_path = source_dir.join("Cargo.toml");
546        let cargo_toml_content = fs::read_to_string(&cargo_toml_path).await?;
547        let updated_cargo_toml =
548            self.update_cargo_toml(&cargo_toml_content, &packages)?;
549        fs::write(&cargo_toml_path, &updated_cargo_toml).await?;
550        // Save previous binary
551        if let Ok(graphix_path) = which::which("graphix") {
552            let date = Local::now().format("%Y%m%d-%H%M%S");
553            let backup_name = format!(
554                "graphix-previous-{date}{}",
555                graphix_path
556                    .extension()
557                    .map(|e| format!(".{}", e.to_string_lossy()))
558                    .unwrap_or_default()
559            );
560            let backup_path = graphix_path.with_file_name(&backup_name);
561            let _ = fs::copy(&graphix_path, &backup_path).await;
562        }
563        // Build and install
564        println!("Building graphix with updated packages (this may take a while)...");
565        let status = Command::new(&self.cargo)
566            .arg("install")
567            .arg("--path")
568            .arg(&source_dir)
569            .arg("--force")
570            .status()
571            .await
572            .context("running cargo install")?;
573        if !status.success() {
574            bail!("cargo install failed with status {status}")
575        }
576        // Clean up old previous binaries (>1 week)
577        self.cleanup_old_binaries().await;
578        println!("Done! Restart graphix to use the updated packages.");
579        Ok(())
580    }
581
582    /// Clean up graphix-previous-* binaries older than 1 week
583    async fn cleanup_old_binaries(&self) {
584        let Ok(graphix_path) = which::which("graphix") else { return };
585        let Some(bin_dir) = graphix_path.parent() else { return };
586        let Ok(mut entries) = fs::read_dir(bin_dir).await else { return };
587        let week_ago =
588            std::time::SystemTime::now() - std::time::Duration::from_secs(7 * 24 * 3600);
589        while let Ok(Some(entry)) = entries.next_entry().await {
590            let name = entry.file_name();
591            let Some(name) = name.to_str() else { continue };
592            if !name.starts_with("graphix-previous-") {
593                continue;
594            }
595            if let Ok(md) = entry.metadata().await {
596                if let Ok(modified) = md.modified() {
597                    if modified < week_ago {
598                        let _ = fs::remove_file(entry.path()).await;
599                    }
600                }
601            }
602        }
603    }
604
605    /// Read the version from a package crate's Cargo.toml at the given path
606    async fn read_package_version(path: &Path) -> Result<String> {
607        let cargo_toml_path = path.join("Cargo.toml");
608        let contents = fs::read_to_string(&cargo_toml_path)
609            .await
610            .with_context(|| format!("reading {}", cargo_toml_path.display()))?;
611        let doc: toml::Value =
612            toml::from_str(&contents).context("parsing package Cargo.toml")?;
613        doc.get("package")
614            .and_then(|p| p.get("version"))
615            .and_then(|v| v.as_str())
616            .map(|s| s.to_string())
617            .ok_or_else(|| anyhow!("no version found in {}", cargo_toml_path.display()))
618    }
619
620    /// Add packages and rebuild
621    pub async fn add_packages(
622        &self,
623        packages: &[PackageId],
624        skip_crates_io_check: bool,
625    ) -> Result<()> {
626        let mut lock = Self::lock_file()?;
627        let _guard = lock.write().context("waiting for package lock")?;
628        let mut installed = read_packages().await?;
629        let mut changed = false;
630        for pkg in packages {
631            let entry = if let Some(path) = pkg.path() {
632                let path = path
633                    .canonicalize()
634                    .with_context(|| format!("resolving path {}", path.display()))?;
635                let version = Self::read_package_version(&path).await?;
636                println!(
637                    "Adding {} @ path {} (version {version})",
638                    pkg.name(),
639                    path.display()
640                );
641                PackageEntry::Path(path)
642            } else if skip_crates_io_check {
643                match pkg.version() {
644                    Some(v) => {
645                        println!("Adding {}@{v}", pkg.name());
646                        PackageEntry::Version(v.to_string())
647                    }
648                    None => bail!(
649                        "version is required for {} when using --skip-crates-io-check",
650                        pkg.name()
651                    ),
652                }
653            } else {
654                let crate_name = pkg.crate_name();
655                let cr =
656                    self.cratesio.get_crate(&crate_name).await.with_context(|| {
657                        format!("package {crate_name} not found on crates.io")
658                    })?;
659                let version = match pkg.version() {
660                    Some(v) => v.to_string(),
661                    None => cr.crate_data.max_version.clone(),
662                };
663                println!("Adding {}@{version}", pkg.name());
664                PackageEntry::Version(version)
665            };
666            installed.insert(pkg.name().to_string(), entry);
667            changed = true;
668        }
669        if changed {
670            let version = graphix_version().await?;
671            self.rebuild(&installed, &version).await?;
672            write_packages(&installed).await?;
673        } else {
674            println!("No changes needed.");
675        }
676        Ok(())
677    }
678
679    /// Remove packages and rebuild
680    pub async fn remove_packages(&self, packages: &[PackageId]) -> Result<()> {
681        let mut lock = Self::lock_file()?;
682        let _guard = lock.write().context("waiting for package lock")?;
683        let mut installed = read_packages().await?;
684        let mut changed = false;
685        for pkg in packages {
686            if pkg.name() == "core" {
687                eprintln!("Cannot remove the core package");
688                continue;
689            }
690            if installed.remove(pkg.name()).is_some() {
691                println!("Removing {}", pkg.name());
692                changed = true;
693            } else {
694                println!("{} is not installed", pkg.name());
695            }
696        }
697        if changed {
698            let version = graphix_version().await?;
699            self.rebuild(&installed, &version).await?;
700            write_packages(&installed).await?;
701        } else {
702            println!("No changes needed.");
703        }
704        Ok(())
705    }
706
707    /// Search crates.io for graphix packages
708    pub async fn search(&self, query: &str) -> Result<()> {
709        let search_query = format!("graphix-package-{query}");
710        let results = self
711            .cratesio
712            .crates(crates_io_api::CratesQuery::builder().search(&search_query).build())
713            .await?;
714        if results.crates.is_empty() {
715            println!("No packages found matching '{query}'");
716        } else {
717            for cr in &results.crates {
718                let name = cr.name.strip_prefix("graphix-package-").unwrap_or(&cr.name);
719                let desc = cr.description.as_deref().unwrap_or("");
720                println!("{name} ({}) - {desc}", cr.max_version);
721            }
722        }
723        Ok(())
724    }
725
726    /// Rebuild the graphix binary from the current packages.toml
727    pub async fn do_rebuild(&self) -> Result<()> {
728        let mut lock = Self::lock_file()?;
729        let _guard = lock.write().context("waiting for package lock")?;
730        let packages = read_packages().await?;
731        let version = graphix_version().await?;
732        self.rebuild(&packages, &version).await
733    }
734
735    /// List installed packages
736    pub async fn list(&self) -> Result<()> {
737        let packages = read_packages().await?;
738        if packages.is_empty() {
739            println!("No packages installed");
740        } else {
741            for (name, version) in &packages {
742                println!("{name}: {version}");
743            }
744        }
745        Ok(())
746    }
747
748    /// Build a standalone graphix binary from a local package directory.
749    ///
750    /// The binary is placed in `package_dir/graphix`. Only the local
751    /// package is included directly — cargo resolves its transitive
752    /// dependencies (including stdlib packages) normally.
753    pub async fn build_standalone(
754        &self,
755        package_dir: &Path,
756        source_override: Option<&Path>,
757    ) -> Result<()> {
758        let package_dir = package_dir
759            .canonicalize()
760            .with_context(|| format!("resolving {}", package_dir.display()))?;
761        // Read the package name from Cargo.toml
762        let cargo_toml_path = package_dir.join("Cargo.toml");
763        let contents = fs::read_to_string(&cargo_toml_path)
764            .await
765            .with_context(|| format!("reading {}", cargo_toml_path.display()))?;
766        let doc: toml::Value =
767            toml::from_str(&contents).context("parsing package Cargo.toml")?;
768        let crate_name = doc
769            .get("package")
770            .and_then(|p| p.get("name"))
771            .and_then(|v| v.as_str())
772            .ok_or_else(|| anyhow!("no package name in {}", cargo_toml_path.display()))?;
773        let short_name =
774            crate_name.strip_prefix("graphix-package-").ok_or_else(|| {
775                anyhow!("package name must start with graphix-package-, got {crate_name}")
776            })?;
777        let mut packages = BTreeMap::new();
778        packages.insert(short_name.to_string(), PackageEntry::Path(package_dir.clone()));
779        let mut lock_storage =
780            if source_override.is_none() { Some(Self::lock_file()?) } else { None };
781        let _guard = lock_storage
782            .as_mut()
783            .map(|l| l.write().context("waiting for package lock"))
784            .transpose()?;
785        let source_dir = if let Some(dir) = source_override {
786            dir.to_path_buf()
787        } else {
788            println!("Unpacking graphix-shell source...");
789            let build_dir = graphix_data_dir()?.join("build");
790            if fs::metadata(&build_dir).await.is_ok() {
791                fs::remove_dir_all(&build_dir).await?;
792            }
793            self.unpack_source(&graphix_version().await?).await?
794        };
795        println!("Generating deps.rs...");
796        let deps_rs = self.generate_deps_rs(&packages)?;
797        fs::write(source_dir.join("src").join("deps.rs"), &deps_rs).await?;
798        println!("Updating Cargo.toml...");
799        let shell_cargo_toml_path = source_dir.join("Cargo.toml");
800        let shell_cargo_toml = fs::read_to_string(&shell_cargo_toml_path).await?;
801        let updated = self.update_cargo_toml(&shell_cargo_toml, &packages)?;
802        fs::write(&shell_cargo_toml_path, &updated).await?;
803        println!("Building standalone binary (this may take a while)...");
804        let status = Command::new(&self.cargo)
805            .arg("build")
806            .arg("--release")
807            .arg("--features")
808            .arg(format!("{crate_name}/standalone"))
809            .current_dir(&source_dir)
810            .status()
811            .await
812            .context("running cargo build")?;
813        if !status.success() {
814            bail!("cargo build --release failed with status {status}")
815        }
816        let bin_name = format!("{short_name}{}", std::env::consts::EXE_SUFFIX);
817        let built = source_dir
818            .join("target")
819            .join("release")
820            .join(format!("graphix{}", std::env::consts::EXE_SUFFIX));
821        let dest = package_dir.join(&bin_name);
822        fs::copy(&built, &dest).await.with_context(|| {
823            format!("copying {} to {}", built.display(), dest.display())
824        })?;
825        println!("Done! Binary written to {}", dest.display());
826        Ok(())
827    }
828
829    /// Query crates.io for the latest version of a crate
830    async fn latest_version(&self, crate_name: &str) -> Result<String> {
831        let cr = self
832            .cratesio
833            .get_crate(crate_name)
834            .await
835            .with_context(|| format!("querying crates.io for {crate_name}"))?;
836        Ok(cr.crate_data.max_version)
837    }
838
839    /// Update graphix to the latest version and rebuild with current packages
840    pub async fn update(&self) -> Result<()> {
841        let mut lock = Self::lock_file()?;
842        let _guard = lock.write().context("waiting for package lock")?;
843        let current = graphix_version().await?;
844        let latest_shell = self.latest_version("graphix-shell").await?;
845        if current == latest_shell {
846            println!("graphix is already up to date (version {current})");
847            return Ok(());
848        }
849        println!("Updating graphix from {current} to {latest_shell}...");
850        let mut packages = read_packages().await?;
851        for (name, entry) in packages.iter_mut() {
852            if is_stdlib_package(name) {
853                if let PackageEntry::Version(_) = entry {
854                    let crate_name = format!("graphix-package-{name}");
855                    let latest = self.latest_version(&crate_name).await?;
856                    println!("  {name}: {entry} -> {latest}");
857                    *entry = PackageEntry::Version(latest);
858                }
859            }
860        }
861        self.rebuild(&packages, &latest_shell).await?;
862        write_packages(&packages).await?;
863        Ok(())
864    }
865}