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