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 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 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 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
406fn 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}