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    ("fs", SKEL.version),
196    ("time", SKEL.version),
197    ("net", SKEL.version),
198    ("re", SKEL.version),
199    ("rand", SKEL.version),
200    ("tui", SKEL.version),
201    ("gui", SKEL.version),
202];
203
204fn is_stdlib_package(name: &str) -> bool {
205    DEFAULT_PACKAGES.iter().any(|(n, _)| *n == name)
206}
207
208/// A package entry in packages.toml — either a version string or a path.
209#[derive(Debug, Clone)]
210pub enum PackageEntry {
211    Version(String),
212    Path(PathBuf),
213}
214
215impl std::fmt::Display for PackageEntry {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        match self {
218            Self::Version(v) => write!(f, "{v}"),
219            Self::Path(p) => write!(f, "path:{}", p.display()),
220        }
221    }
222}
223
224/// Read the packages.toml file, creating it with defaults if it doesn't exist.
225async fn read_packages() -> Result<BTreeMap<String, PackageEntry>> {
226    let path = packages_toml_path()?;
227    match fs::read_to_string(&path).await {
228        Ok(contents) => {
229            let doc: toml::Value =
230                toml::from_str(&contents).context("parsing packages.toml")?;
231            let tbl = doc
232                .get("packages")
233                .and_then(|v| v.as_table())
234                .ok_or_else(|| anyhow!("packages.toml missing [packages] table"))?;
235            let mut packages = BTreeMap::new();
236            for (k, v) in tbl {
237                let entry = match v {
238                    toml::Value::String(s) => PackageEntry::Version(s.clone()),
239                    toml::Value::Table(t) => {
240                        if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
241                            PackageEntry::Path(PathBuf::from(p))
242                        } else {
243                            bail!("package {k}: table entry must have a 'path' key")
244                        }
245                    }
246                    _ => bail!("package {k}: expected a version string or table"),
247                };
248                packages.insert(k.clone(), entry);
249            }
250            Ok(packages)
251        }
252        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
253            let packages: BTreeMap<String, PackageEntry> = DEFAULT_PACKAGES
254                .iter()
255                .map(|(k, v)| (k.to_string(), PackageEntry::Version(v.to_string())))
256                .collect();
257            write_packages(&packages).await?;
258            Ok(packages)
259        }
260        Err(e) => Err(e.into()),
261    }
262}
263
264/// Write the packages.toml file
265async fn write_packages(packages: &BTreeMap<String, PackageEntry>) -> Result<()> {
266    let path = packages_toml_path()?;
267    if let Some(parent) = path.parent() {
268        fs::create_dir_all(parent).await?;
269    }
270    let mut doc = toml::value::Table::new();
271    let mut tbl = toml::value::Table::new();
272    for (k, entry) in packages {
273        match entry {
274            PackageEntry::Version(v) => {
275                tbl.insert(k.clone(), toml::Value::String(v.clone()));
276            }
277            PackageEntry::Path(p) => {
278                let mut t = toml::value::Table::new();
279                t.insert(
280                    "path".to_string(),
281                    toml::Value::String(p.to_string_lossy().into_owned()),
282                );
283                tbl.insert(k.clone(), toml::Value::Table(t));
284            }
285        }
286    }
287    doc.insert("packages".to_string(), toml::Value::Table(tbl));
288    fs::write(&path, toml::to_string_pretty(&doc)?).await?;
289    Ok(())
290}
291
292/// Get the graphix version string from the running binary
293async fn graphix_version() -> Result<String> {
294    let graphix = which::which("graphix").context("can't find the graphix command")?;
295    let c = Command::new(&graphix).arg("--version").stdout(Stdio::piped()).spawn()?;
296    let line = BufReader::new(c.stdout.unwrap())
297        .lines()
298        .next_line()
299        .await?
300        .ok_or_else(|| anyhow!("graphix did not return a version"))?;
301    // version output may be "graphix 0.3.2" or just "0.3.2"
302    Ok(line.split_whitespace().last().unwrap_or(&line).to_string())
303}
304
305// fetch our source from the local cargo cache (preferred method)
306async fn extract_local_source(cargo: &Path, version: &str) -> Result<PathBuf> {
307    let graphix_build_dir = graphix_data_dir()?.join("build");
308    let graphix_dir = graphix_build_dir.join(format!("graphix-shell-{version}"));
309    match fs::metadata(&graphix_build_dir).await {
310        Err(_) => fs::create_dir_all(&graphix_build_dir).await?,
311        Ok(md) if !md.is_dir() => bail!("{graphix_build_dir:?} isn't a directory"),
312        Ok(_) => (),
313    }
314    match fs::metadata(&graphix_dir).await {
315        Ok(md) if !md.is_dir() => bail!("{graphix_dir:?} isn't a directory"),
316        Ok(_) => return Ok(graphix_dir),
317        Err(_) => (),
318    }
319    let package = format!("graphix-shell-{version}");
320    let cargo_root = cargo
321        .parent()
322        .ok_or_else(|| anyhow!("can't find cargo root"))?
323        .parent()
324        .ok_or_else(|| anyhow!("can't find cargo root"))?;
325    let cargo_src = cargo_root.join("registry").join("src");
326    match fs::metadata(&cargo_src).await {
327        Ok(md) if md.is_dir() => (),
328        Err(_) | Ok(_) => bail!("can't find cargo cache {cargo_src:?}"),
329    };
330    let r = task::spawn_blocking({
331        let graphix_dir = graphix_dir.clone();
332        move || -> Result<()> {
333            let src_path = WalkDir::new(&cargo_src)
334                .max_depth(2)
335                .into_iter()
336                .find_map(|e| {
337                    let e = e.ok()?;
338                    if e.file_type().is_dir() && e.path().ends_with(&package) {
339                        return Some(e.into_path());
340                    }
341                    None
342                })
343                .ok_or_else(|| anyhow!("can't find {package} in {cargo_src:?}"))?;
344            cp_r::CopyOptions::new().copy_tree(&src_path, graphix_dir)?;
345            Ok(())
346        }
347    })
348    .await?;
349    match r {
350        Ok(()) => Ok(graphix_dir),
351        Err(e) => {
352            let _ = fs::remove_dir_all(&graphix_dir).await;
353            Err(e)
354        }
355    }
356}
357
358// download our src from crates.io (backup method)
359async fn download_source(
360    crates_io: &AsyncClient,
361    graphix_data_dir: &Path,
362    version: &str,
363) -> Result<PathBuf> {
364    let package = format!("graphix-shell-{version}");
365    let graphix_build_dir = graphix_data_dir.join("build");
366    let graphix_dir = graphix_build_dir.join(&package);
367    match fs::metadata(&graphix_build_dir).await {
368        Err(_) => fs::create_dir_all(&graphix_build_dir).await?,
369        Ok(md) if !md.is_dir() => bail!("{graphix_build_dir:?} isn't a directory"),
370        Ok(_) => (),
371    }
372    match fs::metadata(&graphix_dir).await {
373        Ok(md) if !md.is_dir() => bail!("{graphix_dir:?} isn't a directory"),
374        Ok(_) => return Ok(graphix_dir),
375        Err(_) => (),
376    }
377    let cr = crates_io.get_crate("graphix-shell").await?;
378    let cr_version = cr
379        .versions
380        .into_iter()
381        .find(|v| v.num == version)
382        .ok_or_else(|| anyhow!("can't find version {version} on crates.io"))?;
383    let crate_data_tar_gz =
384        reqwest::get(Url::parse("https://crates.io")?.join(&cr_version.dl_path)?)
385            .await?
386            .bytes()
387            .await?;
388    let r = task::spawn_blocking({
389        let graphix_build_dir = graphix_build_dir.clone();
390        let cargo_toml = graphix_dir.join("Cargo.toml");
391        move || -> Result<()> {
392            use std::io::Read;
393            let mut crate_data_tar = vec![];
394            MultiGzDecoder::new(&crate_data_tar_gz[..])
395                .read_to_end(&mut crate_data_tar)?;
396            tar::Archive::new(&mut &crate_data_tar[..]).unpack(&graphix_build_dir)?;
397            if !std::fs::exists(&cargo_toml)? {
398                bail!("package missing Cargo.toml")
399            }
400            Ok(())
401        }
402    })
403    .await?;
404    match r {
405        Ok(()) => Ok(graphix_dir),
406        Err(e) => {
407            let _ = fs::remove_dir_all(&graphix_dir).await;
408            Err(e)
409        }
410    }
411}
412
413#[derive(Debug, Clone)]
414pub struct PackageId {
415    name: CompactString,
416    version: Option<CompactString>,
417    path: Option<PathBuf>,
418}
419
420impl PackageId {
421    pub fn new(name: &str, version: Option<&str>) -> Self {
422        let name = if name.starts_with("graphix-package-") {
423            CompactString::from(name.strip_prefix("graphix-package-").unwrap())
424        } else {
425            CompactString::from(name)
426        };
427        let version = version.map(CompactString::from);
428        Self { name, version, path: None }
429    }
430
431    pub fn with_path(name: &str, path: PathBuf) -> Self {
432        let name = if name.starts_with("graphix-package-") {
433            CompactString::from(name.strip_prefix("graphix-package-").unwrap())
434        } else {
435            CompactString::from(name)
436        };
437        Self { name, version: None, path: Some(path) }
438    }
439
440    /// Short name without graphix-package- prefix
441    pub fn name(&self) -> &str {
442        &self.name
443    }
444
445    /// The full crate name
446    pub fn crate_name(&self) -> CompactString {
447        format_compact!("graphix-package-{}", self.name)
448    }
449
450    pub fn version(&self) -> Option<&str> {
451        self.version.as_ref().map(|s| s.as_str())
452    }
453
454    pub fn path(&self) -> Option<&Path> {
455        self.path.as_deref()
456    }
457}
458
459/// The Graphix package manager
460pub struct GraphixPM {
461    cratesio: AsyncClient,
462    cargo: PathBuf,
463}
464
465impl GraphixPM {
466    /// Create a new package manager
467    pub async fn new() -> Result<Self> {
468        let cargo = which::which("cargo").context("can't find the cargo command")?;
469        let cratesio = AsyncClient::new(
470            "Graphix Package Manager <eestokes@pm.me>",
471            Duration::from_secs(1),
472        )?;
473        Ok(Self { cratesio, cargo })
474    }
475
476    /// Open the lock file for the graphix data directory.
477    /// Call `.write()` on the returned lock to acquire exclusive access.
478    fn lock_file() -> Result<fd_lock::RwLock<std::fs::File>> {
479        let lock_path = graphix_data_dir()?.join("graphix.lock");
480        if let Some(parent) = lock_path.parent() {
481            std::fs::create_dir_all(parent)?;
482        }
483        let file = std::fs::OpenOptions::new()
484            .create(true)
485            .truncate(false)
486            .read(true)
487            .write(true)
488            .open(&lock_path)
489            .context("opening lock file")?;
490        Ok(fd_lock::RwLock::new(file))
491    }
492
493    /// Unpack a fresh copy of the graphix-shell source. Tries the
494    /// local cargo registry cache first, falls back to downloading
495    /// from crates.io.
496    async fn unpack_source(&self, version: &str) -> Result<PathBuf> {
497        let graphix_data_dir = graphix_data_dir()?;
498        match extract_local_source(&self.cargo, version).await {
499            Ok(p) => Ok(p),
500            Err(local) => {
501                match download_source(&self.cratesio, &graphix_data_dir, version).await {
502                    Ok(p) => Ok(p),
503                    Err(dl) => {
504                        bail!("could not find our source local: {local}, dl: {dl}")
505                    }
506                }
507            }
508        }
509    }
510
511    /// Generate deps.rs from the package list
512    fn generate_deps_rs(
513        &self,
514        packages: &BTreeMap<String, PackageEntry>,
515    ) -> Result<String> {
516        let mut hb = Handlebars::new();
517        hb.register_template_string("deps.rs", SKEL.deps_rs)?;
518        let deps: Vec<serde_json::Value> = packages
519            .keys()
520            .map(|name| {
521                json!({
522                    "crate_name": format!("graphix_package_{}", name.replace('-', "_")),
523                })
524            })
525            .collect();
526        let params = json!({ "deps": deps });
527        Ok(hb.render("deps.rs", &params)?)
528    }
529
530    /// Update Cargo.toml to include package dependencies
531    fn update_cargo_toml(
532        &self,
533        cargo_toml_content: &str,
534        packages: &BTreeMap<String, PackageEntry>,
535    ) -> Result<String> {
536        use toml_edit::DocumentMut;
537        let mut doc: DocumentMut =
538            cargo_toml_content.parse().context("parsing Cargo.toml")?;
539        let deps = doc["dependencies"]
540            .as_table_mut()
541            .ok_or_else(|| anyhow!("Cargo.toml missing [dependencies]"))?;
542        let to_remove: Vec<String> = deps
543            .iter()
544            .filter_map(|(k, _)| {
545                if k.starts_with("graphix-package-") {
546                    Some(k.to_string())
547                } else {
548                    None
549                }
550            })
551            .collect();
552        for k in to_remove {
553            deps.remove(&k);
554        }
555        for (name, entry) in packages {
556            let crate_name = format!("graphix-package-{name}");
557            match entry {
558                PackageEntry::Version(version) => {
559                    deps[&crate_name] = toml_edit::value(version);
560                }
561                PackageEntry::Path(path) => {
562                    let mut tbl = toml_edit::InlineTable::new();
563                    tbl.insert(
564                        "path",
565                        toml_edit::Value::from(path.to_string_lossy().as_ref()),
566                    );
567                    deps[&crate_name] = toml_edit::Item::Value(tbl.into());
568                }
569            }
570        }
571        // Snapshot dep names so we can release the mutable borrow on doc
572        let dep_names: BTreeSet<String> =
573            deps.iter().map(|(k, _)| k.to_string()).collect();
574        // Clean up [features] that reference removed graphix-package-* deps
575        if let Some(features) = doc.get_mut("features").and_then(|f| f.as_table_mut()) {
576            let mut empty_features = Vec::new();
577            for (feat, val) in features.iter_mut() {
578                if let Some(arr) = val.as_array_mut() {
579                    arr.retain(|v| match v.as_str() {
580                        Some(s) if s.starts_with("dep:graphix-package-") => {
581                            dep_names.contains(&s["dep:".len()..])
582                        }
583                        Some(s) if s.starts_with("graphix-package-") => {
584                            dep_names.contains(s)
585                        }
586                        _ => true,
587                    });
588                    if arr.is_empty() {
589                        empty_features.push(feat.to_string());
590                    }
591                }
592            }
593            for feat in &empty_features {
594                features.remove(feat);
595            }
596            // Clean up default to remove references to deleted features
597            if let Some(default) =
598                features.get_mut("default").and_then(|v| v.as_array_mut())
599            {
600                default.retain(|v| match v.as_str() {
601                    Some(s) => !empty_features.contains(&s.to_string()),
602                    _ => true,
603                });
604            }
605        }
606        Ok(doc.to_string())
607    }
608
609    /// Rebuild the graphix binary with the given package set
610    async fn rebuild(
611        &self,
612        packages: &BTreeMap<String, PackageEntry>,
613        version: &str,
614    ) -> Result<()> {
615        println!("Unpacking graphix-shell source...");
616        // Delete existing build dir to get a fresh source
617        let build_dir = graphix_data_dir()?.join("build");
618        if fs::metadata(&build_dir).await.is_ok() {
619            fs::remove_dir_all(&build_dir).await?;
620        }
621        let source_dir = self.unpack_source(version).await?;
622        // Generate deps.rs
623        println!("Generating deps.rs...");
624        let deps_rs = self.generate_deps_rs(&packages)?;
625        fs::write(source_dir.join("src").join("deps.rs"), &deps_rs).await?;
626        // Update Cargo.toml with package dependencies
627        println!("Updating Cargo.toml...");
628        let cargo_toml_path = source_dir.join("Cargo.toml");
629        let cargo_toml_content = fs::read_to_string(&cargo_toml_path).await?;
630        let updated_cargo_toml =
631            self.update_cargo_toml(&cargo_toml_content, &packages)?;
632        fs::write(&cargo_toml_path, &updated_cargo_toml).await?;
633        // Save previous binary
634        if let Ok(graphix_path) = which::which("graphix") {
635            let date = Local::now().format("%Y%m%d-%H%M%S");
636            let backup_name = format!(
637                "graphix-previous-{date}{}",
638                graphix_path
639                    .extension()
640                    .map(|e| format!(".{}", e.to_string_lossy()))
641                    .unwrap_or_default()
642            );
643            let backup_path = graphix_path.with_file_name(&backup_name);
644            let _ = fs::copy(&graphix_path, &backup_path).await;
645        }
646        // Build and install
647        println!("Building graphix with updated packages (this may take a while)...");
648        let status = Command::new(&self.cargo)
649            .arg("install")
650            .arg("--path")
651            .arg(&source_dir)
652            .arg("--force")
653            .status()
654            .await
655            .context("running cargo install")?;
656        if !status.success() {
657            bail!("cargo install failed with status {status}")
658        }
659        // Clean up old previous binaries (>1 week)
660        self.cleanup_old_binaries().await;
661        println!("Done! Restart graphix to use the updated packages.");
662        Ok(())
663    }
664
665    /// Clean up graphix-previous-* binaries older than 1 week
666    async fn cleanup_old_binaries(&self) {
667        let Ok(graphix_path) = which::which("graphix") else { return };
668        let Some(bin_dir) = graphix_path.parent() else { return };
669        let Ok(mut entries) = fs::read_dir(bin_dir).await else { return };
670        let week_ago =
671            std::time::SystemTime::now() - std::time::Duration::from_secs(7 * 24 * 3600);
672        while let Ok(Some(entry)) = entries.next_entry().await {
673            let name = entry.file_name();
674            let Some(name) = name.to_str() else { continue };
675            if !name.starts_with("graphix-previous-") {
676                continue;
677            }
678            if let Ok(md) = entry.metadata().await {
679                if let Ok(modified) = md.modified() {
680                    if modified < week_ago {
681                        let _ = fs::remove_file(entry.path()).await;
682                    }
683                }
684            }
685        }
686    }
687
688    /// Read the version from a package crate's Cargo.toml at the given path
689    async fn read_package_version(path: &Path) -> Result<String> {
690        let cargo_toml_path = path.join("Cargo.toml");
691        let contents = fs::read_to_string(&cargo_toml_path)
692            .await
693            .with_context(|| format!("reading {}", cargo_toml_path.display()))?;
694        let doc: toml::Value =
695            toml::from_str(&contents).context("parsing package Cargo.toml")?;
696        doc.get("package")
697            .and_then(|p| p.get("version"))
698            .and_then(|v| v.as_str())
699            .map(|s| s.to_string())
700            .ok_or_else(|| anyhow!("no version found in {}", cargo_toml_path.display()))
701    }
702
703    /// Add packages and rebuild
704    pub async fn add_packages(
705        &self,
706        packages: &[PackageId],
707        skip_crates_io_check: bool,
708    ) -> Result<()> {
709        let mut lock = Self::lock_file()?;
710        let _guard = lock.write().context("waiting for package lock")?;
711        let mut installed = read_packages().await?;
712        let mut changed = false;
713        for pkg in packages {
714            let entry = if let Some(path) = pkg.path() {
715                let path = path
716                    .canonicalize()
717                    .with_context(|| format!("resolving path {}", path.display()))?;
718                let version = Self::read_package_version(&path).await?;
719                println!(
720                    "Adding {} @ path {} (version {version})",
721                    pkg.name(),
722                    path.display()
723                );
724                PackageEntry::Path(path)
725            } else if skip_crates_io_check {
726                match pkg.version() {
727                    Some(v) => {
728                        println!("Adding {}@{v}", pkg.name());
729                        PackageEntry::Version(v.to_string())
730                    }
731                    None => bail!(
732                        "version is required for {} when using --skip-crates-io-check",
733                        pkg.name()
734                    ),
735                }
736            } else {
737                let crate_name = pkg.crate_name();
738                let cr =
739                    self.cratesio.get_crate(&crate_name).await.with_context(|| {
740                        format!("package {crate_name} not found on crates.io")
741                    })?;
742                let version = match pkg.version() {
743                    Some(v) => v.to_string(),
744                    None => cr.crate_data.max_version.clone(),
745                };
746                println!("Adding {}@{version}", pkg.name());
747                PackageEntry::Version(version)
748            };
749            installed.insert(pkg.name().to_string(), entry);
750            changed = true;
751        }
752        if changed {
753            let version = graphix_version().await?;
754            self.rebuild(&installed, &version).await?;
755            write_packages(&installed).await?;
756        } else {
757            println!("No changes needed.");
758        }
759        Ok(())
760    }
761
762    /// Remove packages and rebuild
763    pub async fn remove_packages(&self, packages: &[PackageId]) -> Result<()> {
764        let mut lock = Self::lock_file()?;
765        let _guard = lock.write().context("waiting for package lock")?;
766        let mut installed = read_packages().await?;
767        let mut changed = false;
768        for pkg in packages {
769            if pkg.name() == "core" {
770                eprintln!("Cannot remove the core package");
771                continue;
772            }
773            if installed.remove(pkg.name()).is_some() {
774                println!("Removing {}", pkg.name());
775                changed = true;
776            } else {
777                println!("{} is not installed", pkg.name());
778            }
779        }
780        if changed {
781            let version = graphix_version().await?;
782            self.rebuild(&installed, &version).await?;
783            write_packages(&installed).await?;
784        } else {
785            println!("No changes needed.");
786        }
787        Ok(())
788    }
789
790    /// Search crates.io for graphix packages
791    pub async fn search(&self, query: &str) -> Result<()> {
792        let search_query = format!("graphix-package-{query}");
793        let results = self
794            .cratesio
795            .crates(crates_io_api::CratesQuery::builder().search(&search_query).build())
796            .await?;
797        if results.crates.is_empty() {
798            println!("No packages found matching '{query}'");
799        } else {
800            for cr in &results.crates {
801                let name = cr.name.strip_prefix("graphix-package-").unwrap_or(&cr.name);
802                let desc = cr.description.as_deref().unwrap_or("");
803                println!("{name} ({}) - {desc}", cr.max_version);
804            }
805        }
806        Ok(())
807    }
808
809    /// Rebuild the graphix binary from the current packages.toml
810    pub async fn do_rebuild(&self) -> Result<()> {
811        let mut lock = Self::lock_file()?;
812        let _guard = lock.write().context("waiting for package lock")?;
813        let packages = read_packages().await?;
814        let version = graphix_version().await?;
815        self.rebuild(&packages, &version).await
816    }
817
818    /// List installed packages
819    pub async fn list(&self) -> Result<()> {
820        let packages = read_packages().await?;
821        if packages.is_empty() {
822            println!("No packages installed");
823        } else {
824            for (name, version) in &packages {
825                println!("{name}: {version}");
826            }
827        }
828        Ok(())
829    }
830
831    /// Build a standalone graphix binary from a local package directory.
832    ///
833    /// The binary is placed in `package_dir/graphix`. Only the local
834    /// package is included directly — cargo resolves its transitive
835    /// dependencies (including stdlib packages) normally.
836    pub async fn build_standalone(
837        &self,
838        package_dir: &Path,
839        source_override: Option<&Path>,
840    ) -> Result<()> {
841        let package_dir = package_dir
842            .canonicalize()
843            .with_context(|| format!("resolving {}", package_dir.display()))?;
844        // Read the package name from Cargo.toml
845        let cargo_toml_path = package_dir.join("Cargo.toml");
846        let contents = fs::read_to_string(&cargo_toml_path)
847            .await
848            .with_context(|| format!("reading {}", cargo_toml_path.display()))?;
849        let doc: toml::Value =
850            toml::from_str(&contents).context("parsing package Cargo.toml")?;
851        let crate_name = doc
852            .get("package")
853            .and_then(|p| p.get("name"))
854            .and_then(|v| v.as_str())
855            .ok_or_else(|| anyhow!("no package name in {}", cargo_toml_path.display()))?;
856        let short_name =
857            crate_name.strip_prefix("graphix-package-").ok_or_else(|| {
858                anyhow!("package name must start with graphix-package-, got {crate_name}")
859            })?;
860        let mut packages = BTreeMap::new();
861        packages.insert(short_name.to_string(), PackageEntry::Path(package_dir.clone()));
862        let mut lock_storage =
863            if source_override.is_none() { Some(Self::lock_file()?) } else { None };
864        let _guard = lock_storage
865            .as_mut()
866            .map(|l| l.write().context("waiting for package lock"))
867            .transpose()?;
868        let source_dir = if let Some(dir) = source_override {
869            dir.to_path_buf()
870        } else {
871            println!("Unpacking graphix-shell source...");
872            let build_dir = graphix_data_dir()?.join("build");
873            if fs::metadata(&build_dir).await.is_ok() {
874                fs::remove_dir_all(&build_dir).await?;
875            }
876            self.unpack_source(&graphix_version().await?).await?
877        };
878        println!("Generating deps.rs...");
879        let deps_rs = self.generate_deps_rs(&packages)?;
880        fs::write(source_dir.join("src").join("deps.rs"), &deps_rs).await?;
881        println!("Updating Cargo.toml...");
882        let shell_cargo_toml_path = source_dir.join("Cargo.toml");
883        let shell_cargo_toml = fs::read_to_string(&shell_cargo_toml_path).await?;
884        let updated = self.update_cargo_toml(&shell_cargo_toml, &packages)?;
885        fs::write(&shell_cargo_toml_path, &updated).await?;
886        println!("Building standalone binary (this may take a while)...");
887        let status = Command::new(&self.cargo)
888            .arg("build")
889            .arg("--release")
890            .arg("--features")
891            .arg(format!("{crate_name}/standalone"))
892            .current_dir(&source_dir)
893            .status()
894            .await
895            .context("running cargo build")?;
896        if !status.success() {
897            bail!("cargo build --release failed with status {status}")
898        }
899        let bin_name = format!("{short_name}{}", std::env::consts::EXE_SUFFIX);
900        let built = source_dir
901            .join("target")
902            .join("release")
903            .join(format!("graphix{}", std::env::consts::EXE_SUFFIX));
904        let dest = package_dir.join(&bin_name);
905        fs::copy(&built, &dest).await.with_context(|| {
906            format!("copying {} to {}", built.display(), dest.display())
907        })?;
908        println!("Done! Binary written to {}", dest.display());
909        Ok(())
910    }
911
912    /// Query crates.io for the latest version of a crate
913    async fn latest_version(&self, crate_name: &str) -> Result<String> {
914        let cr = self
915            .cratesio
916            .get_crate(crate_name)
917            .await
918            .with_context(|| format!("querying crates.io for {crate_name}"))?;
919        Ok(cr.crate_data.max_version)
920    }
921
922    /// Update graphix to the latest version and rebuild with current packages
923    pub async fn update(&self) -> Result<()> {
924        let mut lock = Self::lock_file()?;
925        let _guard = lock.write().context("waiting for package lock")?;
926        let current = graphix_version().await?;
927        let latest_shell = self.latest_version("graphix-shell").await?;
928        if current == latest_shell {
929            println!("graphix is already up to date (version {current})");
930            return Ok(());
931        }
932        println!("Updating graphix from {current} to {latest_shell}...");
933        let mut packages = read_packages().await?;
934        for (name, entry) in packages.iter_mut() {
935            if is_stdlib_package(name) {
936                if let PackageEntry::Version(_) = entry {
937                    let crate_name = format!("graphix-package-{name}");
938                    let latest = self.latest_version(&crate_name).await?;
939                    println!("  {name}: {entry} -> {latest}");
940                    *entry = PackageEntry::Version(latest);
941                }
942            }
943        }
944        self.rebuild(&packages, &latest_shell).await?;
945        write_packages(&packages).await?;
946        Ok(())
947    }
948}