1use 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 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 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 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}