cortenforge_tools/
services.rs1use std::env;
2use std::fs;
3use std::io;
4use std::io::BufRead;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command};
7
8use serde::Deserialize;
9#[cfg(any(feature = "tui", feature = "scheduler"))]
10use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
11use thiserror::Error;
12
13use crate::ToolConfig;
14
15#[derive(Debug, Clone, Deserialize)]
16pub struct RunManifestSummary {
17 pub schema_version: Option<u32>,
18 pub seed: Option<u64>,
19 pub output_root: Option<PathBuf>,
20 pub run_dir: Option<PathBuf>,
21 pub started_at_unix: Option<f64>,
22 pub max_frames: Option<u32>,
23}
24
25#[derive(Debug, Clone)]
26pub struct RunInfo {
27 pub path: PathBuf,
28 pub manifest: Option<RunManifestSummary>,
29 pub label_count: usize,
30 pub image_count: usize,
31 pub overlay_count: usize,
32}
33
34#[derive(Debug, Error)]
35pub enum ServiceError {
36 #[error("io error: {0}")]
37 Io(#[from] io::Error),
38 #[error("json error: {0}")]
39 Json(#[from] serde_json::Error),
40}
41
42pub fn list_runs(root: &Path) -> Result<Vec<RunInfo>, ServiceError> {
44 let mut out = Vec::new();
45 for entry in fs::read_dir(root)? {
46 let entry = entry?;
47 let path = entry.path();
48 if !path.is_dir() {
49 continue;
50 }
51 let name_ok = path
52 .file_name()
53 .and_then(|s| s.to_str())
54 .map(|s| s.starts_with("run_"))
55 .unwrap_or(false);
56 if !name_ok {
57 continue;
58 }
59 let manifest_path = path.join("run_manifest.json");
60 let manifest = if manifest_path.exists() {
61 let data = fs::read(&manifest_path)?;
62 serde_json::from_slice::<RunManifestSummary>(&data).ok()
63 } else {
64 None
65 };
66 let counts = count_artifacts(&path);
67 out.push(RunInfo {
68 path: path.clone(),
69 manifest,
70 label_count: counts.0,
71 image_count: counts.1,
72 overlay_count: counts.2,
73 });
74 }
75 out.sort_by(|a, b| a.path.cmp(&b.path));
76 Ok(out)
77}
78
79fn count_artifacts(run_dir: &Path) -> (usize, usize, usize) {
80 let labels = run_dir.join("labels");
81 let images = run_dir.join("images");
82 let overlays = run_dir.join("overlays");
83 let count_ext = |dir: &Path, ext: &str| -> usize {
84 fs::read_dir(dir)
85 .into_iter()
86 .flatten()
87 .filter_map(|e| e.ok())
88 .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some(ext))
89 .count()
90 };
91 (
92 count_ext(&labels, "json"),
93 count_ext(&images, "png"),
94 count_ext(&overlays, "png"),
95 )
96}
97
98#[derive(Debug, Clone)]
99pub struct ServiceCommand {
100 pub program: PathBuf,
101 pub args: Vec<String>,
102}
103
104pub fn spawn(cmd: &ServiceCommand) -> io::Result<Child> {
105 Command::new(&cmd.program).args(&cmd.args).spawn()
106}
107
108fn bin_path(bin: &str) -> io::Result<PathBuf> {
109 let mut exe = env::current_exe()?;
110 exe.pop(); exe.push(bin);
112 Ok(exe)
113}
114
115#[derive(Debug, Clone)]
116pub struct DatagenOptions {
117 pub output_root: PathBuf,
118 pub seed: Option<u64>,
119 pub max_frames: Option<u32>,
120 pub headless: bool,
121 pub prune_empty: bool,
122 pub prune_output_root: Option<PathBuf>,
123}
124
125pub fn datagen_command(opts: &DatagenOptions) -> io::Result<ServiceCommand> {
127 let cfg = ToolConfig::load();
128 datagen_command_with_config(&cfg, opts)
129}
130
131pub fn datagen_command_with_config(
132 cfg: &ToolConfig,
133 opts: &DatagenOptions,
134) -> io::Result<ServiceCommand> {
135 let bin = if cfg.sim_bin.is_absolute() {
136 cfg.sim_bin.clone()
137 } else {
138 bin_path(cfg.sim_bin.to_string_lossy().as_ref())?
139 };
140 let mut args = Vec::new();
141 if cfg.datagen_args.is_empty() {
142 args.push("--mode".into());
143 args.push("datagen".into());
144 } else {
145 args.extend(cfg.datagen_args.iter().cloned());
146 }
147 if let Some(seed) = opts.seed {
148 args.push("--seed".into());
149 args.push(seed.to_string());
150 }
151 if opts.headless {
152 args.push("--headless".into());
153 }
154 args.push("--output-root".into());
155 args.push(opts.output_root.display().to_string());
156 if let Some(max) = opts.max_frames {
157 args.push("--max-frames".into());
158 args.push(max.to_string());
159 }
160 if opts.headless && !opts.prune_empty && opts.prune_output_root.is_none() {
161 }
163 if opts.prune_empty {
164 args.push("--prune-empty".into());
165 if let Some(root) = &opts.prune_output_root {
166 args.push("--prune-output-root".into());
167 args.push(root.display().to_string());
168 }
169 }
170 Ok(ServiceCommand { program: bin, args })
171}
172
173#[derive(Debug, Clone)]
174pub struct TrainOptions {
175 pub input_root: PathBuf,
176 pub val_ratio: f32,
177 pub batch_size: usize,
178 pub epochs: usize,
179 pub seed: Option<u64>,
180 pub drop_last: bool,
181 pub real_val_dir: Option<PathBuf>,
182 pub status_file: Option<PathBuf>,
183}
184
185pub fn train_command(opts: &TrainOptions) -> io::Result<ServiceCommand> {
187 let cfg = ToolConfig::load();
188 train_command_with_config(&cfg, opts)
189}
190
191pub fn train_command_with_config(
192 cfg: &ToolConfig,
193 opts: &TrainOptions,
194) -> io::Result<ServiceCommand> {
195 let bin = if cfg.train_bin.is_absolute() {
196 cfg.train_bin.clone()
197 } else {
198 bin_path(cfg.train_bin.to_string_lossy().as_ref())?
199 };
200 let mut args = Vec::new();
201 if cfg.training_args.is_empty() {
202 args.extend([
203 "--input-root".into(),
204 opts.input_root.display().to_string(),
205 "--val-ratio".into(),
206 opts.val_ratio.to_string(),
207 "--batch-size".into(),
208 opts.batch_size.to_string(),
209 "--epochs".into(),
210 opts.epochs.to_string(),
211 ]);
212 } else {
213 args.extend(cfg.training_args.iter().cloned());
214 }
215 if let Some(seed) = opts.seed {
216 args.push("--seed".into());
217 args.push(seed.to_string());
218 }
219 if opts.drop_last {
220 args.push("--drop-last".into());
221 }
222 if let Some(val_dir) = &opts.real_val_dir {
223 args.push("--real-val-dir".into());
224 args.push(val_dir.display().to_string());
225 }
226 if let Some(status) = &opts.status_file {
227 args.push("--status-file".into());
228 args.push(status.display().to_string());
229 }
230 Ok(ServiceCommand { program: bin, args })
231}
232
233pub fn read_metrics(
235 path: &Path,
236 limit: Option<usize>,
237) -> Result<Vec<serde_json::Value>, ServiceError> {
238 let file = fs::File::open(path)?;
239 let reader = io::BufReader::new(file);
240 let mut rows: Vec<serde_json::Value> = reader
241 .lines()
242 .map_while(Result::ok)
243 .filter_map(|line| serde_json::from_str(&line).ok())
244 .collect();
245 if let Some(n) = limit {
246 if rows.len() > n {
247 rows.drain(0..rows.len().saturating_sub(n));
248 }
249 }
250 Ok(rows)
251}
252
253pub fn read_log_tail(path: &Path, limit: usize) -> Result<Vec<String>, ServiceError> {
255 let file = fs::File::open(path)?;
256 let reader = io::BufReader::new(file);
257 let mut lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
258 if lines.len() > limit {
259 lines.drain(0..lines.len().saturating_sub(limit));
260 }
261 Ok(lines)
262}
263
264#[cfg(any(feature = "tui", feature = "scheduler"))]
265pub fn is_process_running(pid: u32) -> bool {
266 let mut sys =
267 System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::new()));
268 let pid = Pid::from_u32(pid);
269 sys.refresh_process(pid)
270}
271
272#[cfg(any(feature = "tui", feature = "scheduler"))]
273pub fn read_status(path: &Path) -> Option<serde_json::Value> {
274 let data = std::fs::read(path).ok()?;
275 serde_json::from_slice(&data).ok()
276}