Skip to main content

rpi_cli/
dev_extension.rs

1//! Development workflow for Rust-native rpi extensions.
2//!
3//! `rpi dev` detects a Cargo `cdylib`, builds it, stages a uniquely named copy
4//! under `.rpi/extensions/.dev`, and optionally watches its source tree. Unique
5//! staging directories are required on Windows because a loaded DLL cannot be
6//! overwritten until the old `Library` handle is released by `/reload`.
7
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, SystemTime};
15
16use serde::Deserialize;
17
18use crate::args::Args;
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct DevOptions {
22    pub package: Option<String>,
23    pub release: bool,
24    pub watch: bool,
25    pub passthrough: Vec<String>,
26    pub help: bool,
27}
28
29pub fn parse_args(args: &[String]) -> Result<DevOptions, String> {
30    let mut options = DevOptions {
31        watch: true,
32        ..DevOptions::default()
33    };
34    let mut passthrough = false;
35    let mut index = 0;
36    while index < args.len() {
37        let value = &args[index];
38        if passthrough {
39            options.passthrough.push(value.clone());
40        } else {
41            match value.as_str() {
42                "--" => passthrough = true,
43                "--help" | "-h" => options.help = true,
44                "--release" => options.release = true,
45                "--no-watch" => options.watch = false,
46                "--package" | "-P" => {
47                    index += 1;
48                    let package = args
49                        .get(index)
50                        .filter(|value| !value.trim().is_empty())
51                        .ok_or("rpi dev --package requires a package name")?;
52                    options.package = Some(package.clone());
53                }
54                _ => options.passthrough.push(value.clone()),
55            }
56        }
57        index += 1;
58    }
59    Ok(options)
60}
61
62pub fn print_help() {
63    println!(
64        "Usage: rpi dev [dev options] [--] [rpi options/messages...]\n\n\
65Build the current Rust rpi extension, load it, and watch for changes.\n\n\
66Dev options:\n  \
67--package, -P <name>  Select a cdylib package in a multi-package workspace\n  \
68--release              Build with Cargo's release profile\n  \
69--no-watch             Build once; /reload still rebuilds manually\n  \
70--help, -h             Show this help\n\n\
71Examples:\n  \
72rpi dev\n  \
73rpi dev --package my-extension\n  \
74rpi dev --release -- --model gateway/model\n"
75    );
76}
77
78#[derive(Debug, Deserialize)]
79struct CargoMetadata {
80    packages: Vec<MetadataPackage>,
81    workspace_root: PathBuf,
82}
83
84#[derive(Debug, Deserialize)]
85struct MetadataPackage {
86    name: String,
87    manifest_path: PathBuf,
88    targets: Vec<MetadataTarget>,
89}
90
91#[derive(Debug, Deserialize)]
92struct MetadataTarget {
93    name: String,
94    crate_types: Vec<String>,
95}
96
97#[derive(Debug, Clone)]
98struct ExtensionProject {
99    package: String,
100    target: String,
101    manifest: PathBuf,
102    package_root: PathBuf,
103    workspace_root: PathBuf,
104}
105
106pub struct DevExtension {
107    project: ExtensionProject,
108    release: bool,
109    watch: bool,
110    stage_root: PathBuf,
111    current_stage: Mutex<Option<PathBuf>>,
112    build_lock: Mutex<()>,
113    generation: AtomicU64,
114    stop: AtomicBool,
115}
116
117impl DevExtension {
118    pub fn detect(cwd: &Path, options: &DevOptions) -> Result<Arc<Self>, String> {
119        let project = detect_project(cwd, options.package.as_deref())?;
120        let stage_root = project
121            .workspace_root
122            .join(".rpi/extensions/.dev")
123            .join(format!(
124                "{}-{}",
125                safe_name(&project.package),
126                std::process::id()
127            ));
128        std::fs::create_dir_all(&stage_root).map_err(|error| {
129            format!(
130                "could not create extension staging directory {}: {error}",
131                stage_root.display()
132            )
133        })?;
134        Ok(Arc::new(Self {
135            project,
136            release: options.release,
137            watch: options.watch,
138            stage_root,
139            current_stage: Mutex::new(None),
140            build_lock: Mutex::new(()),
141            generation: AtomicU64::new(0),
142            stop: AtomicBool::new(false),
143        }))
144    }
145
146    pub fn package_name(&self) -> &str {
147        &self.project.package
148    }
149
150    pub fn watch_enabled(&self) -> bool {
151        self.watch
152    }
153
154    /// Compile and stage a fresh copy. The current stage changes only after a
155    /// successful Cargo build and copy, so failed reloads keep the live plugin.
156    pub fn rebuild(&self) -> Result<PathBuf, String> {
157        let _guard = self.build_lock.lock().unwrap();
158        eprintln!("dev: building extension {}...", self.project.package);
159        let artifact = build_cdylib(&self.project, self.release)?;
160        let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
161        let stage = self.stage_root.join(generation.to_string());
162        std::fs::create_dir_all(&stage)
163            .map_err(|error| format!("could not create {}: {error}", stage.display()))?;
164        let file_name = artifact
165            .file_name()
166            .ok_or_else(|| format!("Cargo artifact has no file name: {}", artifact.display()))?;
167        let staged = stage.join(file_name);
168        std::fs::copy(&artifact, &staged).map_err(|error| {
169            format!(
170                "could not stage extension {} as {}: {error}",
171                artifact.display(),
172                staged.display()
173            )
174        })?;
175        *self.current_stage.lock().unwrap() = Some(stage.clone());
176        eprintln!("dev: built {}", staged.display());
177        Ok(stage)
178    }
179
180    pub fn apply_to_args(&self, args: &mut Args) -> Result<(), String> {
181        let current = self
182            .current_stage
183            .lock()
184            .unwrap()
185            .clone()
186            .ok_or("development extension has not been built")?;
187        args.extensions_dir
188            .retain(|path| !path.starts_with(&self.stage_root));
189        args.extensions_dir.push(current);
190        Ok(())
191    }
192
193    /// Poll the extension inputs in a background thread. A change signals the
194    /// existing reload mailbox; the TUI serializes compilation and the plugin
195    /// swap on its async main loop, so watcher and manual reloads share one path.
196    pub fn start_watcher(
197        self: &Arc<Self>,
198        mailbox: rpi_extensions::ReloadMailbox,
199    ) -> Option<std::thread::JoinHandle<()>> {
200        if !self.watch {
201            return None;
202        }
203        let weak = Arc::downgrade(self);
204        let mut fingerprint = source_fingerprint(&self.project);
205        eprintln!(
206            "dev: watching {} (use /reload to rebuild manually)",
207            self.project.package_root.display()
208        );
209        Some(std::thread::spawn(move || loop {
210            std::thread::sleep(Duration::from_millis(650));
211            let Some(dev) = weak.upgrade() else {
212                break;
213            };
214            if dev.stop.load(Ordering::SeqCst) {
215                break;
216            }
217            let next = source_fingerprint(&dev.project);
218            if next == fingerprint {
219                continue;
220            }
221            fingerprint = next;
222            if mailbox.signal().is_err() {
223                eprintln!("dev: source changed; run /reload to rebuild it");
224            }
225        }))
226    }
227
228    pub fn stop_watcher(&self) {
229        self.stop.store(true, Ordering::SeqCst);
230    }
231
232    pub fn cleanup(&self) {
233        if std::fs::remove_dir_all(&self.stage_root).is_ok() {
234            remove_empty_dev_dir(&self.stage_root);
235            return;
236        }
237        // A host-side keepalive can retain the mapped DLL until process exit.
238        // A detached copy of rpi retries after this process releases the file.
239        if let Ok(exe) = std::env::current_exe() {
240            let _ = Command::new(exe)
241                .arg("__rpi_dev_cleanup")
242                .arg(&self.stage_root)
243                .stdin(Stdio::null())
244                .stdout(Stdio::null())
245                .stderr(Stdio::null())
246                .spawn();
247        }
248    }
249}
250
251impl Drop for DevExtension {
252    fn drop(&mut self) {
253        self.stop.store(true, Ordering::SeqCst);
254    }
255}
256
257fn detect_project(cwd: &Path, requested: Option<&str>) -> Result<ExtensionProject, String> {
258    let output = Command::new("cargo")
259        .args(["metadata", "--format-version", "1", "--no-deps"])
260        .current_dir(cwd)
261        .stdin(Stdio::null())
262        .output()
263        .map_err(|error| format!("could not execute cargo metadata: {error}"))?;
264    if !output.status.success() {
265        return Err(format!(
266            "cargo metadata failed:\n{}",
267            String::from_utf8_lossy(&output.stderr).trim()
268        ));
269    }
270    let metadata: CargoMetadata = serde_json::from_slice(&output.stdout)
271        .map_err(|error| format!("invalid cargo metadata output: {error}"))?;
272    let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
273    let mut candidates = Vec::new();
274    for package in metadata.packages {
275        if requested.is_some_and(|name| package.name != name) {
276            continue;
277        }
278        let package_root = package
279            .manifest_path
280            .parent()
281            .map(Path::to_path_buf)
282            .unwrap_or_default();
283        for target in package
284            .targets
285            .iter()
286            .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
287        {
288            candidates.push(ExtensionProject {
289                package: package.name.clone(),
290                target: target.name.clone(),
291                manifest: package.manifest_path.clone(),
292                package_root: package_root.clone(),
293                workspace_root: metadata.workspace_root.clone(),
294            });
295        }
296    }
297    if candidates.is_empty() {
298        return Err(match requested {
299            Some(name) => format!("Cargo package `{name}` is not a cdylib extension"),
300            None => "current Cargo project does not contain a cdylib extension".to_string(),
301        });
302    }
303    if requested.is_none() {
304        let mut local: Vec<_> = candidates
305            .iter()
306            .filter(|project| cwd.starts_with(&project.package_root))
307            .cloned()
308            .collect();
309        local.sort_by_key(|project| std::cmp::Reverse(project.package_root.components().count()));
310        if let Some(project) = local.into_iter().next() {
311            return Ok(project);
312        }
313    }
314    if candidates.len() == 1 {
315        return Ok(candidates.remove(0));
316    }
317    Err(format!(
318        "multiple cdylib extensions found: {}. Select one with `rpi dev --package <name>`",
319        candidates
320            .iter()
321            .map(|project| project.package.as_str())
322            .collect::<Vec<_>>()
323            .join(", ")
324    ))
325}
326
327fn build_cdylib(project: &ExtensionProject, release: bool) -> Result<PathBuf, String> {
328    let mut command = Command::new("cargo");
329    command
330        .arg("build")
331        .args(["--manifest-path"])
332        .arg(&project.manifest)
333        .args([
334            "--package",
335            &project.package,
336            "--message-format=json-render-diagnostics",
337        ])
338        .current_dir(&project.workspace_root)
339        .stdin(Stdio::null())
340        .stdout(Stdio::piped())
341        .stderr(Stdio::piped());
342    if release {
343        command.arg("--release");
344    }
345    let output = command
346        .output()
347        .map_err(|error| format!("could not execute cargo build: {error}"))?;
348    let mut artifact = None;
349    for line in String::from_utf8_lossy(&output.stdout).lines() {
350        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
351            continue;
352        };
353        if let Some(rendered) = value
354            .get("message")
355            .and_then(|message| message.get("rendered"))
356            .and_then(serde_json::Value::as_str)
357        {
358            eprint!("{rendered}");
359        }
360        if value.get("reason").and_then(serde_json::Value::as_str) != Some("compiler-artifact")
361            || value
362                .get("target")
363                .and_then(|target| target.get("name"))
364                .and_then(serde_json::Value::as_str)
365                != Some(project.target.as_str())
366        {
367            continue;
368        }
369        artifact = value
370            .get("filenames")
371            .and_then(serde_json::Value::as_array)
372            .into_iter()
373            .flatten()
374            .filter_map(serde_json::Value::as_str)
375            .map(PathBuf::from)
376            .find(|path| is_cdylib(path));
377    }
378    if !output.stderr.is_empty() {
379        eprint!("{}", String::from_utf8_lossy(&output.stderr));
380    }
381    if !output.status.success() {
382        return Err(format!("cargo build exited with {}", output.status));
383    }
384    artifact.ok_or_else(|| {
385        format!(
386            "Cargo built `{}` but did not report a cdylib artifact for `{}`",
387            project.package, project.target
388        )
389    })
390}
391
392fn is_cdylib(path: &Path) -> bool {
393    matches!(
394        path.extension()
395            .and_then(|value| value.to_str())
396            .map(|value| value.to_ascii_lowercase())
397            .as_deref(),
398        Some("dll" | "so" | "dylib")
399    )
400}
401
402fn safe_name(value: &str) -> String {
403    value
404        .chars()
405        .map(|ch| {
406            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
407                ch
408            } else {
409                '-'
410            }
411        })
412        .collect()
413}
414
415fn source_fingerprint(project: &ExtensionProject) -> u64 {
416    let mut hasher = DefaultHasher::new();
417    fingerprint_path(&project.package_root.join("src"), &mut hasher);
418    for path in [
419        project.manifest.clone(),
420        project.package_root.join("build.rs"),
421        project.workspace_root.join("Cargo.toml"),
422        project.workspace_root.join("Cargo.lock"),
423    ] {
424        fingerprint_file(&path, &mut hasher);
425    }
426    hasher.finish()
427}
428
429fn fingerprint_path(path: &Path, hasher: &mut DefaultHasher) {
430    let Ok(entries) = std::fs::read_dir(path) else {
431        return;
432    };
433    let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect();
434    paths.sort();
435    for path in paths {
436        if path.is_dir() {
437            fingerprint_path(&path, hasher);
438        } else if matches!(
439            path.extension().and_then(|value| value.to_str()),
440            Some("rs" | "toml" | "json")
441        ) {
442            fingerprint_file(&path, hasher);
443        }
444    }
445}
446
447fn fingerprint_file(path: &Path, hasher: &mut DefaultHasher) {
448    if let Ok(metadata) = std::fs::metadata(path) {
449        path.hash(hasher);
450        metadata.len().hash(hasher);
451        metadata
452            .modified()
453            .unwrap_or(SystemTime::UNIX_EPOCH)
454            .hash(hasher);
455    }
456}
457
458pub fn run_cleanup_helper(args: &[String]) -> i32 {
459    let Some(raw) = args.first() else {
460        return 2;
461    };
462    let path = PathBuf::from(raw);
463    if !is_dev_stage_root(&path) {
464        return 2;
465    }
466    for _ in 0..100 {
467        match std::fs::remove_dir_all(&path) {
468            Ok(()) => {
469                remove_empty_dev_dir(&path);
470                return 0;
471            }
472            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return 0,
473            Err(_) => std::thread::sleep(Duration::from_millis(100)),
474        }
475    }
476    1
477}
478
479fn is_dev_stage_root(path: &Path) -> bool {
480    path.parent()
481        .and_then(Path::file_name)
482        .and_then(|v| v.to_str())
483        == Some(".dev")
484        && path
485            .parent()
486            .and_then(Path::parent)
487            .and_then(Path::file_name)
488            .and_then(|v| v.to_str())
489            == Some("extensions")
490        && path
491            .parent()
492            .and_then(Path::parent)
493            .and_then(Path::parent)
494            .and_then(Path::file_name)
495            .and_then(|v| v.to_str())
496            == Some(".rpi")
497}
498
499fn remove_empty_dev_dir(stage_root: &Path) {
500    if let Some(parent) = stage_root.parent() {
501        let _ = std::fs::remove_dir(parent);
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn parses_dev_options_without_consuming_rpi_options() {
511        let options = parse_args(&[
512            "--package".into(),
513            "demo".into(),
514            "--release".into(),
515            "--model".into(),
516            "gateway/model".into(),
517        ])
518        .unwrap();
519        assert_eq!(options.package.as_deref(), Some("demo"));
520        assert!(options.release);
521        assert!(options.watch);
522        assert_eq!(options.passthrough, ["--model", "gateway/model"]);
523    }
524
525    #[test]
526    fn no_watch_is_honored() {
527        let options = parse_args(&["--no-watch".into()]).unwrap();
528        assert!(!options.watch);
529    }
530
531    #[test]
532    fn cleanup_helper_rejects_non_dev_paths() {
533        assert!(!is_dev_stage_root(Path::new("target/debug")));
534        assert!(is_dev_stage_root(Path::new(
535            "workspace/.rpi/extensions/.dev/example-123"
536        )));
537    }
538}