use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
pub(crate) const COMMAND_PREFIX: &str = "workflow:";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DiscoveredWorkflow {
pub(crate) relative_path: String,
pub(crate) absolute_path: PathBuf,
pub(crate) label: String,
}
impl DiscoveredWorkflow {
pub(crate) fn command_name(&self) -> Option<String> {
(!self.label.is_empty() && !self.label.contains(char::is_whitespace))
.then(|| format!("{COMMAND_PREFIX}{}", self.label))
}
pub(crate) fn command_usage(&self) -> Option<String> {
self.command_name()
.map(|name| format!("/{name} [inputs JSON]"))
}
pub(crate) fn command_description(&self) -> String {
format!("start workflow {}", self.relative_path)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct StartCommand {
pub(crate) workflow: DiscoveredWorkflow,
pub(crate) inputs: BTreeMap<String, serde_json::Value>,
}
pub(crate) fn discover_workflow_sources(workspace: &Path) -> Vec<DiscoveredWorkflow> {
let root = workspace.join(".rho").join("workflows");
if !root.is_dir() {
return Vec::new();
}
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(&root) else {
return Vec::new();
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_file() {
if is_star_file(&path) {
push_discovered(workspace, path, &mut found);
}
continue;
}
if !file_type.is_dir() {
continue;
}
let entry_star = path.join("workflow.star");
if std::fs::symlink_metadata(&entry_star).is_ok_and(|metadata| metadata.is_file()) {
push_discovered(workspace, entry_star, &mut found);
}
}
found.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
found
}
pub(crate) fn command_workflows(workspace: &Path) -> Vec<DiscoveredWorkflow> {
let mut workflows = discover_workflow_sources(workspace);
workflows.retain(|workflow| workflow.command_name().is_some());
workflows.sort_by_cached_key(|workflow| workflow.label.to_ascii_lowercase());
workflows.dedup_by(|left, right| left.label.eq_ignore_ascii_case(&right.label));
workflows
}
pub(crate) fn resolve_start_command(
workspace: &Path,
name: &str,
args: &str,
) -> Option<Result<StartCommand, String>> {
name.get(..COMMAND_PREFIX.len())
.filter(|prefix| prefix.eq_ignore_ascii_case(COMMAND_PREFIX))
.map(|_| resolve_label(workspace, &name[COMMAND_PREFIX.len()..], args))
}
fn resolve_label(workspace: &Path, label: &str, args: &str) -> Result<StartCommand, String> {
let mut matches = discover_workflow_sources(workspace)
.into_iter()
.filter(|workflow| workflow.label.eq_ignore_ascii_case(label))
.collect::<Vec<_>>();
let workflow = match matches.len() {
0 => return Err(format!("no workflow named '{label}' under .rho/workflows")),
1 => matches.remove(0),
_ => {
let paths = matches
.iter()
.map(|workflow| workflow.relative_path.as_str())
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"'{label}' matches more than one workflow ({paths}); rename one"
));
}
};
Ok(StartCommand {
workflow,
inputs: parse_inputs(args)?,
})
}
fn parse_inputs(args: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
let args = args.trim();
if args.is_empty() {
return Ok(BTreeMap::new());
}
serde_json::from_str(args).map_err(|error| {
format!("inputs must be one JSON object, such as {{\"target\": \"src\"}}: {error}")
})
}
fn is_star_file(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()) == Some("star")
}
fn push_discovered(workspace: &Path, absolute: PathBuf, out: &mut Vec<DiscoveredWorkflow>) {
let Ok(relative) = absolute.strip_prefix(workspace) else {
return;
};
let relative_path = relative
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if relative_path.is_empty() {
return;
}
let label = workflow_label(&relative_path);
out.push(DiscoveredWorkflow {
relative_path,
absolute_path: absolute,
label,
});
}
fn workflow_label(relative_path: &str) -> String {
let path = Path::new(relative_path);
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "workflow.star")
{
if let Some(parent) = path.parent().and_then(|parent| parent.file_name()) {
return parent.to_string_lossy().into_owned();
}
}
path.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| relative_path.to_owned())
}
#[cfg(test)]
#[path = "discover_tests.rs"]
mod tests;