mod output; mod progress; pub(super) mod setup; mod transport; use clap::{Subcommand, ValueEnum}; use ferese_activities::{Data, Id, Source, State, Template, Value}; use ferese_ipc::activities::{OwnerScope, Publish, Request, VERSION}; use serde_json::json; use std::error::Error; use std::io::{self, BufRead, Read, Write}; use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::path::Path; use std::process::{Command, Stdio}; #[derive(Debug, Subcommand)] pub enum Action { /// Enable shell activities and add the panel item if not already configured. Setup { #[arg(long)] config: std::path::PathBuf, }, Start { local: String, #[arg(long, default_value = "ferese")] icon: String, #[arg(long)] title: String, #[arg(long)] progress: Option, #[arg(long)] message: Option, #[arg(long)] source: Option, /// Typed Progress, Timer, Media, or List payload as JSON. #[arg(long)] template: Option, #[arg(long)] remaining_ms: Option, #[arg(long)] output: Option, #[arg(long, default_value_t = 100)] priority: u8, #[arg(long)] lease_ms: Option, }, Update { local: String, #[arg(long)] progress: Option, #[arg(long)] message: Option, #[arg(long)] remaining_ms: Option, #[arg(long)] lease_ms: Option, #[arg(long)] handle: Option, }, End { local: String, #[arg(long)] result: ResultKind, #[arg(long)] message: Option, #[arg(long)] handle: Option, }, Cancel { local: String, #[arg(long)] handle: Option, }, /// Report an observed command to opt-in rules without executing it. Hook { local: String, #[arg(long)] command: String, #[arg(long)] cwd: String, }, /// Show all current activity records. List, } #[derive(Clone, Copy, Debug, ValueEnum)] pub enum ResultKind { Ok, Fail, Cancelled, } fn handle(value: Option<&str>) -> Result, Box> { value .map(|value| serde_json::from_str(value).map_err(Into::into)) .transpose() } fn value( progress: Option, message: Option<&str>, remaining_ms: Option, ) -> Result, Box> { if usize::from(progress.is_some()) + usize::from(message.is_some()) + usize::from(remaining_ms.is_some()) > 1 { return Err("choose one activity value".into()); } Ok(progress .map(Value::Progress) .or_else(|| message.map(|message| Value::Text(message.to_owned()))) .or_else(|| { remaining_ms.map(|remaining_ms| Value::Countdown { remaining_ms, sampled_at: 0, }) })) } fn publish( socket: &Path, owner: OwnerScope, publish: Publish, ) -> Result> { publish_with_timeout(socket, owner, publish, std::time::Duration::from_secs(2)) } fn publish_with_timeout( socket: &Path, owner: OwnerScope, publish: Publish, timeout: std::time::Duration, ) -> Result> { let mut stream = transport::connect(socket, timeout)?; super::request( &mut stream, "activity-publish", json!(Request { version: VERSION, owner, publish }), ) } pub fn command(action: &Action, socket: &Path, json_output: bool) -> Result<(), Box> { let request = match action { Action::Setup { .. } => unreachable!("setup is an offline command"), Action::Start { local, icon, title, progress, remaining_ms, message, source, template, output, priority, lease_ms, } => { let value = value(*progress, message.as_deref(), *remaining_ms)? .unwrap_or(Value::Indeterminate); let data = Data { source: if let Some(source) = source { serde_json::from_value(json!(source))? } else if remaining_ms.is_some() { Source::Timer } else { Source::Publisher }, output: output.clone(), priority: *priority, icon: icon.clone(), title: title.clone(), template: if let Some(template) = template { if template.len() > 4_096 { return Err("template exceeds message budget".into()); } serde_json::from_str(template)? } else if remaining_ms.is_some() { Template::Timer } else { Template::Progress }, value, }; Publish::Start { local: local.clone(), data, lease_ms: *lease_ms, } } Action::Update { local, progress, message, remaining_ms, lease_ms, handle: token, } => Publish::Update { local: local.clone(), handle: handle(token.as_deref())?, value: value(*progress, message.as_deref(), *remaining_ms)?, lease_ms: *lease_ms, }, Action::End { local, result, message, handle: token, } => Publish::End { local: local.clone(), handle: handle(token.as_deref())?, result: match result { ResultKind::Ok => State::Done, ResultKind::Fail => State::Failed, ResultKind::Cancelled => State::Cancelled, }, message: message.clone(), }, Action::Cancel { local, handle: token, } => Publish::Cancel { local: local.clone(), handle: handle(token.as_deref())?, }, Action::Hook { local, command, cwd, } => Publish::Hook { local: local.clone(), command: command.clone(), cwd: cwd.clone(), }, Action::List => { let mut stream = std::os::unix::net::UnixStream::connect(socket)?; return super::output::print( &super::request(&mut stream, "activity-get", json!({}))?, json_output, ); } }; let result = publish(socket, OwnerScope::Parent, request)?; // Handles are machine-readable even when general queries use tabular output. super::output::print(&result, true) } static CHILD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); extern "C" fn terminate(signal: i32) { let pid = CHILD.load(std::sync::atomic::Ordering::Relaxed); // SAFETY: kill is async-signal-safe; the PID belongs to this wrapper's child. if pid > 0 { unsafe { libc::kill(pid, signal); } } } pub fn run( title: Option<&str>, args: &[String], socket: Option<&Path>, cargo_json: bool, ) -> Result<(), Box> { let Some(program) = args.first() else { return Err("run requires a command after --".into()); }; let local = format!("run-{}", std::process::id()); let mut token = None; let mut configured_adapter = false; if let Some(socket) = socket { let metadata = args.join(" "); if metadata.len() <= 4_096 { let result = publish( socket, OwnerScope::Publisher, Publish::Run { local: local.clone(), title: title.map(str::to_owned), command: metadata, cwd: std::env::current_dir() .ok() .map(|path| path.to_string_lossy().into_owned()) .filter(|cwd| cwd.len() <= 1_024) .unwrap_or_default(), }, ) .ok(); if let Some(result) = result { configured_adapter = result["progress"] == "cargo-json"; token = serde_json::from_value(result["handle"].clone()).ok(); } } } let adapter = (configured_adapter || cargo_json) && program == "cargo" && args.get(1).is_some_and(|operation| { matches!( operation.as_str(), "build" | "check" | "test" | "run" | "clippy" ) }); let mut command = Command::new(program); command.args(&args[1..]); if adapter && !args.iter().any(|arg| arg.starts_with("--message-format")) { // Cargo options must precede the program arguments following `--`. let split = args[1..] .iter() .position(|arg| arg == "--") .map(|index| index + 1) .unwrap_or(args.len()); command = Command::new(program); command .args(&args[1..split]) .arg("--message-format=json-render-diagnostics") .args(&args[split..]); } command .stdin(Stdio::inherit()) .stderr(Stdio::inherit()) .stdout(if adapter { Stdio::piped() } else { Stdio::inherit() }); // Terminal-generated signals reach both processes in the shell's job group. // The wrapper waits for the actual child result; the child resets inherited // dispositions before exec. SIGTSTP/SIGCONT retain normal job control. unsafe { libc::signal(libc::SIGINT, libc::SIG_IGN); libc::signal(libc::SIGQUIT, libc::SIG_IGN); libc::signal(libc::SIGTERM, terminate as *const () as usize); command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_DFL); libc::signal(libc::SIGQUIT, libc::SIG_DFL); libc::signal(libc::SIGTERM, libc::SIG_DFL); Ok(()) }); } let mut child = match command.spawn() { Ok(child) => child, Err(error) => { if let (Some(socket), Some(handle)) = (socket, token) { let _ = publish( socket, OwnerScope::Publisher, Publish::End { local, handle: Some(handle), result: State::Failed, message: Some("Could not start command".into()), }, ); } return Err(error.into()); } }; CHILD.store(child.id() as i32, std::sync::atomic::Ordering::Relaxed); let publisher = socket .zip(token) .map(|(socket, handle)| progress::Publisher::new(socket.to_owned(), local, handle)); let drain_deadline = output::Deadline::default(); let reader = child.stdout.take().map(|stdout| { let deadline = drain_deadline.clone(); let updates = publisher .as_ref() .map(|publisher| publisher.updates.clone()); let (done, finished) = std::sync::mpsc::sync_channel(1); std::thread::spawn(move || { let mut input = io::BufReader::new(output::Output { pipe: stdout, deadline, }); let mut stdout = io::stdout().lock(); let mut units = 0_u64; let mut last = std::time::Instant::now(); loop { let mut bytes = Vec::new(); // Read bounded chunks, preserving arbitrary non-JSON output. let Ok(count) = input.by_ref().take(65_537).read_until(b'\n', &mut bytes) else { break; }; if count == 0 { break; } if bytes.len() <= 65_536 && bytes.first() == Some(&b'{') && let Ok(message) = serde_json::from_slice::(&bytes) { if message["reason"] == "compiler-message" { if let Some(rendered) = message["message"]["rendered"].as_str() { let _ = stdout.write_all(rendered.as_bytes()); } } else if message["reason"] == "compiler-artifact" { units = units.saturating_add(1); } else if message["reason"] != "build-finished" && message["reason"] != "build-script-executed" { let _ = stdout.write_all(&bytes); } if last.elapsed() >= std::time::Duration::from_millis(200) { if let Some(updates) = &updates { updates.progress(units); } last = std::time::Instant::now(); } continue; } if stdout .write_all(&bytes) .and_then(|_| stdout.flush()) .is_err() { break; } } let _ = stdout.flush(); let _ = done.send(()); }); finished }); let status = child.wait()?; CHILD.store(0, std::sync::atomic::Ordering::Relaxed); *drain_deadline.lock().unwrap() = Some(std::time::Instant::now() + std::time::Duration::from_millis(250)); if let Some(reader) = reader { // Descendants may inherit the pipe after the direct child exits. Drain // buffered output briefly, but never wait for those descendants' EOF. let _ = reader.recv_timeout(std::time::Duration::from_millis(350)); } if let Some(publisher) = publisher { let result = if matches!( status.signal(), Some(libc::SIGINT | libc::SIGTERM | libc::SIGQUIT) ) { State::Cancelled } else if status.success() { State::Done } else { State::Failed }; publisher.finish(result); } if let Some(signal) = status.signal() { // Preserve signal termination as well as the conventional shell status. unsafe { libc::signal(signal, libc::SIG_DFL); libc::raise(signal); } std::process::exit(128 + signal); } std::process::exit(status.code().unwrap_or(1)); }