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        eprintln!("dev: building extension {}...", self.project.package);
165        let artifact = build_cdylib(&self.project, self.release)?;
166        let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
167        let stage = self.stage_root.join(generation.to_string());
168        std::fs::create_dir_all(&stage)
169            .map_err(|error| format!("could not create {}: {error}", stage.display()))?;
170        let file_name = artifact
171            .file_name()
172            .ok_or_else(|| format!("Cargo artifact has no file name: {}", artifact.display()))?;
173        let staged = stage.join(file_name);
174        std::fs::copy(&artifact, &staged).map_err(|error| {
175            format!(
176                "could not stage extension {} as {}: {error}",
177                artifact.display(),
178                staged.display()
179            )
180        })?;
181        *self.current_stage.lock().unwrap() = Some(stage.clone());
182        eprintln!("dev: built {}", staged.display());
183        Ok(stage)
184    }
185
186    pub fn apply_to_args(&self, args: &mut Args) -> Result<(), String> {
187        let current = self
188            .current_stage
189            .lock()
190            .unwrap()
191            .clone()
192            .ok_or("development extension has not been built")?;
193        if self.local_only {
194            args.dev_local_only = true;
195            args.extensions_dir.clear();
196            args.extension.clear();
197            args.skill.clear();
198            args.prompt_template.clear();
199        } else {
200            args.extensions_dir
201                .retain(|path| !path.starts_with(&self.stage_root));
202        }
203        args.extensions_dir.push(current);
204        Ok(())
205    }
206
207    /// Poll the extension inputs in a background thread. A change signals the
208    /// existing reload mailbox; the TUI serializes compilation and the plugin
209    /// swap on its async main loop, so watcher and manual reloads share one path.
210    pub fn start_watcher(
211        self: &Arc<Self>,
212        mailbox: rpi_extensions::ReloadMailbox,
213    ) -> Option<std::thread::JoinHandle<()>> {
214        if !self.watch {
215            return None;
216        }
217        let weak = Arc::downgrade(self);
218        let mut fingerprint = source_fingerprint(&self.project);
219        eprintln!(
220            "dev: watching {} (use /reload to rebuild manually)",
221            self.project.package_root.display()
222        );
223        Some(std::thread::spawn(move || loop {
224            std::thread::sleep(Duration::from_millis(650));
225            let Some(dev) = weak.upgrade() else {
226                break;
227            };
228            if dev.stop.load(Ordering::SeqCst) {
229                break;
230            }
231            let next = source_fingerprint(&dev.project);
232            if next == fingerprint {
233                continue;
234            }
235            fingerprint = next;
236            if mailbox.signal().is_err() {
237                eprintln!("dev: source changed; run /reload to rebuild it");
238            }
239        }))
240    }
241
242    pub fn stop_watcher(&self) {
243        self.stop.store(true, Ordering::SeqCst);
244    }
245
246    pub fn cleanup(&self) {
247        if std::fs::remove_dir_all(&self.stage_root).is_ok() {
248            remove_empty_dev_dir(&self.stage_root);
249            return;
250        }
251        // A host-side keepalive can retain the mapped DLL until process exit.
252        // A detached copy of rpi retries after this process releases the file.
253        if let Ok(exe) = std::env::current_exe() {
254            let _ = Command::new(exe)
255                .arg("__rpi_dev_cleanup")
256                .arg(&self.stage_root)
257                .stdin(Stdio::null())
258                .stdout(Stdio::null())
259                .stderr(Stdio::null())
260                .spawn();
261        }
262    }
263}
264
265impl Drop for DevExtension {
266    fn drop(&mut self) {
267        self.stop.store(true, Ordering::SeqCst);
268    }
269}
270
271fn detect_project(cwd: &Path, requested: Option<&str>) -> Result<ExtensionProject, String> {
272    let output = Command::new("cargo")
273        .args(["metadata", "--format-version", "1", "--no-deps"])
274        .current_dir(cwd)
275        .stdin(Stdio::null())
276        .output()
277        .map_err(|error| format!("could not execute cargo metadata: {error}"))?;
278    if !output.status.success() {
279        return Err(format!(
280            "cargo metadata failed:\n{}",
281            String::from_utf8_lossy(&output.stderr).trim()
282        ));
283    }
284    let metadata: CargoMetadata = serde_json::from_slice(&output.stdout)
285        .map_err(|error| format!("invalid cargo metadata output: {error}"))?;
286    let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
287    let mut candidates = Vec::new();
288    for package in metadata.packages {
289        if requested.is_some_and(|name| package.name != name) {
290            continue;
291        }
292        let package_root = package
293            .manifest_path
294            .parent()
295            .map(Path::to_path_buf)
296            .unwrap_or_default();
297        for target in package
298            .targets
299            .iter()
300            .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
301        {
302            candidates.push(ExtensionProject {
303                package: package.name.clone(),
304                target: target.name.clone(),
305                manifest: package.manifest_path.clone(),
306                package_root: package_root.clone(),
307                workspace_root: metadata.workspace_root.clone(),
308            });
309        }
310    }
311    if candidates.is_empty() {
312        return Err(match requested {
313            Some(name) => format!("Cargo package `{name}` is not a cdylib extension"),
314            None => "current Cargo project does not contain a cdylib extension".to_string(),
315        });
316    }
317    if requested.is_none() {
318        let mut local: Vec<_> = candidates
319            .iter()
320            .filter(|project| cwd.starts_with(&project.package_root))
321            .cloned()
322            .collect();
323        local.sort_by_key(|project| std::cmp::Reverse(project.package_root.components().count()));
324        if let Some(project) = local.into_iter().next() {
325            return Ok(project);
326        }
327    }
328    if candidates.len() == 1 {
329        return Ok(candidates.remove(0));
330    }
331    Err(format!(
332        "multiple cdylib extensions found: {}. Select one with `rpi dev --package <name>`",
333        candidates
334            .iter()
335            .map(|project| project.package.as_str())
336            .collect::<Vec<_>>()
337            .join(", ")
338    ))
339}
340
341fn build_cdylib(project: &ExtensionProject, release: bool) -> Result<PathBuf, String> {
342    let mut command = Command::new("cargo");
343    command
344        .arg("build")
345        .args(["--manifest-path"])
346        .arg(&project.manifest)
347        .args([
348            "--package",
349            &project.package,
350            "--message-format=json-render-diagnostics",
351        ])
352        .current_dir(&project.workspace_root)
353        .stdin(Stdio::null())
354        .stdout(Stdio::piped())
355        .stderr(Stdio::piped());
356    if release {
357        command.arg("--release");
358    }
359    let output = command
360        .output()
361        .map_err(|error| format!("could not execute cargo build: {error}"))?;
362    let mut artifact = None;
363    for line in String::from_utf8_lossy(&output.stdout).lines() {
364        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
365            continue;
366        };
367        if let Some(rendered) = value
368            .get("message")
369            .and_then(|message| message.get("rendered"))
370            .and_then(serde_json::Value::as_str)
371        {
372            eprint!("{rendered}");
373        }
374        if value.get("reason").and_then(serde_json::Value::as_str) != Some("compiler-artifact")
375            || value
376                .get("target")
377                .and_then(|target| target.get("name"))
378                .and_then(serde_json::Value::as_str)
379                != Some(project.target.as_str())
380        {
381            continue;
382        }
383        artifact = value
384            .get("filenames")
385            .and_then(serde_json::Value::as_array)
386            .into_iter()
387            .flatten()
388            .filter_map(serde_json::Value::as_str)
389            .map(PathBuf::from)
390            .find(|path| is_cdylib(path));
391    }
392    if !output.stderr.is_empty() {
393        eprint!("{}", String::from_utf8_lossy(&output.stderr));
394    }
395    if !output.status.success() {
396        return Err(format!("cargo build exited with {}", output.status));
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
406fn is_cdylib(path: &Path) -> bool {
407    matches!(
408        path.extension()
409            .and_then(|value| value.to_str())
410            .map(|value| value.to_ascii_lowercase())
411            .as_deref(),
412        Some("dll" | "so" | "dylib")
413    )
414}
415
416fn safe_name(value: &str) -> String {
417    value
418        .chars()
419        .map(|ch| {
420            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
421                ch
422            } else {
423                '-'
424            }
425        })
426        .collect()
427}
428
429fn source_fingerprint(project: &ExtensionProject) -> u64 {
430    let mut hasher = DefaultHasher::new();
431    fingerprint_path(&project.package_root.join("src"), &mut hasher);
432    for path in [
433        project.manifest.clone(),
434        project.package_root.join("build.rs"),
435        project.workspace_root.join("Cargo.toml"),
436        project.workspace_root.join("Cargo.lock"),
437    ] {
438        fingerprint_file(&path, &mut hasher);
439    }
440    hasher.finish()
441}
442
443fn fingerprint_path(path: &Path, hasher: &mut DefaultHasher) {
444    let Ok(entries) = std::fs::read_dir(path) else {
445        return;
446    };
447    let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect();
448    paths.sort();
449    for path in paths {
450        if path.is_dir() {
451            fingerprint_path(&path, hasher);
452        } else if matches!(
453            path.extension().and_then(|value| value.to_str()),
454            Some("rs" | "toml" | "json")
455        ) {
456            fingerprint_file(&path, hasher);
457        }
458    }
459}
460
461fn fingerprint_file(path: &Path, hasher: &mut DefaultHasher) {
462    if let Ok(metadata) = std::fs::metadata(path) {
463        path.hash(hasher);
464        metadata.len().hash(hasher);
465        metadata
466            .modified()
467            .unwrap_or(SystemTime::UNIX_EPOCH)
468            .hash(hasher);
469    }
470}
471
472pub fn run_cleanup_helper(args: &[String]) -> i32 {
473    let Some(raw) = args.first() else {
474        return 2;
475    };
476    let path = PathBuf::from(raw);
477    if !is_dev_stage_root(&path) {
478        return 2;
479    }
480    for _ in 0..100 {
481        match std::fs::remove_dir_all(&path) {
482            Ok(()) => {
483                remove_empty_dev_dir(&path);
484                return 0;
485            }
486            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return 0,
487            Err(_) => std::thread::sleep(Duration::from_millis(100)),
488        }
489    }
490    1
491}
492
493fn is_dev_stage_root(path: &Path) -> bool {
494    path.parent()
495        .and_then(Path::file_name)
496        .and_then(|v| v.to_str())
497        == Some(".dev")
498        && path
499            .parent()
500            .and_then(Path::parent)
501            .and_then(Path::file_name)
502            .and_then(|v| v.to_str())
503            == Some("extensions")
504        && path
505            .parent()
506            .and_then(Path::parent)
507            .and_then(Path::parent)
508            .and_then(Path::file_name)
509            .and_then(|v| v.to_str())
510            == Some(".rpi")
511}
512
513fn remove_empty_dev_dir(stage_root: &Path) {
514    if let Some(parent) = stage_root.parent() {
515        let _ = std::fs::remove_dir(parent);
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn parses_dev_options_without_consuming_rpi_options() {
525        let options = parse_args(&[
526            "--package".into(),
527            "demo".into(),
528            "--release".into(),
529            "--model".into(),
530            "gateway/model".into(),
531        ])
532        .unwrap();
533        assert_eq!(options.package.as_deref(), Some("demo"));
534        assert!(options.release);
535        assert!(options.watch);
536        assert_eq!(options.passthrough, ["--model", "gateway/model"]);
537    }
538
539    #[test]
540    fn no_watch_is_honored() {
541        let options = parse_args(&["--no-watch".into()]).unwrap();
542        assert!(!options.watch);
543    }
544
545    #[test]
546    fn local_only_is_honored_without_leaking_to_passthrough() {
547        let options = parse_args(&[
548            "--local-only".into(),
549            "--model".into(),
550            "gateway/model".into(),
551        ])
552        .unwrap();
553        assert!(options.local_only);
554        assert_eq!(options.passthrough, ["--model", "gateway/model"]);
555    }
556
557    #[test]
558    fn local_only_apply_keeps_only_the_current_stage() {
559        let stage_root = PathBuf::from("workspace/.rpi/extensions/.dev/demo-123");
560        let current_stage = stage_root.join("1");
561        let dev = DevExtension {
562            project: ExtensionProject {
563                package: "demo".into(),
564                target: "demo".into(),
565                manifest: PathBuf::from("workspace/Cargo.toml"),
566                package_root: PathBuf::from("workspace"),
567                workspace_root: PathBuf::from("workspace"),
568            },
569            release: false,
570            watch: true,
571            local_only: true,
572            stage_root,
573            current_stage: Mutex::new(Some(current_stage.clone())),
574            build_lock: Mutex::new(()),
575            generation: AtomicU64::new(1),
576            stop: AtomicBool::new(false),
577        };
578        let mut args = Args {
579            extensions_dir: vec![PathBuf::from("global/extensions")],
580            extension: vec![PathBuf::from("other.dll")],
581            skill: vec![PathBuf::from("other-skill")],
582            prompt_template: vec![PathBuf::from("other-prompt.md")],
583            ..Args::default()
584        };
585
586        dev.apply_to_args(&mut args).unwrap();
587
588        assert!(args.dev_local_only);
589        assert_eq!(args.extensions_dir, [current_stage]);
590        assert!(args.extension.is_empty());
591        assert!(args.skill.is_empty());
592        assert!(args.prompt_template.is_empty());
593    }
594
595    #[test]
596    fn cleanup_helper_rejects_non_dev_paths() {
597        assert!(!is_dev_stage_root(Path::new("target/debug")));
598        assert!(is_dev_stage_root(Path::new(
599            "workspace/.rpi/extensions/.dev/example-123"
600        )));
601    }
602}