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