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