Skip to main content

graphix_package/
lib.rs

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