use super::discovery;
use crate::domain::action::uses_ref::UsesRef;
use crate::domain::file::parsed::{FileKind, Parsed, Step};
use crate::domain::file::scan::Error as WorkflowError;
use crate::domain::file::site::{Id, JobId, Origin, Slot, StepIndex, WorkflowPath};
use crate::regex::static_regex;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
static_regex!(USES_RE, r"^([^@\s]+)@([^\s#]+)");
#[derive(Debug, Error)]
enum IoWorkflowError {
#[error("read error: {}", path.display())]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("YAML parse error: {}", path.display())]
Parse {
path: PathBuf,
source: Box<serde_saphyr::Error>,
},
}
impl From<IoWorkflowError> for WorkflowError {
fn from(err: IoWorkflowError) -> Self {
match err {
IoWorkflowError::Read { path, source } => WorkflowError::ScanFailed {
reason: format!("failed to read {}: {}", path.display(), source),
},
IoWorkflowError::Parse { path, source } => WorkflowError::ParseFailed {
path: path.to_string_lossy().to_string(),
reason: source.to_string(),
},
}
}
}
#[derive(Debug, Clone)]
struct ExtractedAction {
uses_ref: UsesRef,
site: Id,
origin: Origin,
}
fn extract_steps(
steps: &[Step],
workflow_rel_path: &WorkflowPath,
slot_for: impl Fn(StepIndex) -> Slot,
out: &mut Vec<ExtractedAction>,
) {
for (step_idx, step) in steps.iter().enumerate() {
let Some(uses) = step.uses_ref() else {
continue;
};
let Some(cap) = USES_RE.captures(uses) else {
continue;
};
let action_name = cap[1].to_string();
let uses_ref = cap[2].to_string();
if action_name.starts_with('.') || action_name.starts_with("docker://") {
continue;
}
let Ok(index) = StepIndex::try_from(step_idx) else {
continue;
};
let comment = step.uses_comment().map(ToOwned::to_owned);
out.push(ExtractedAction {
uses_ref: UsesRef::new(action_name, uses_ref, comment),
site: Id {
file: workflow_rel_path.clone(),
slot: slot_for(index),
},
origin: Origin {
line: step.uses_line(),
},
});
}
}
pub struct FileScanner {
repo_root: PathBuf,
}
impl FileScanner {
#[must_use]
pub fn new(repo_root: &Path) -> Self {
Self {
repo_root: repo_root.to_path_buf(),
}
}
fn rel_path(&self, workflow_path: &Path) -> WorkflowPath {
WorkflowPath::new(
workflow_path
.strip_prefix(&self.repo_root)
.unwrap_or(workflow_path)
.to_string_lossy()
.into_owned(),
)
}
fn find_managed_paths(&self) -> Result<Vec<PathBuf>, WorkflowError> {
discovery::managed_paths(&self.repo_root)
}
fn find_managed(&self) -> Result<Vec<discovery::ManagedFile>, WorkflowError> {
discovery::managed_files(&self.repo_root)
}
fn extract_workflow(
workflow_path: &Path,
workflow_rel_path: &WorkflowPath,
kind: FileKind,
) -> Result<(Parsed, Vec<ExtractedAction>), IoWorkflowError> {
let content =
fs::read_to_string(workflow_path).map_err(|source| IoWorkflowError::Read {
path: workflow_path.to_path_buf(),
source,
})?;
let parsed =
Parsed::parse(workflow_rel_path.clone(), kind, &content).map_err(|source| {
IoWorkflowError::Parse {
path: workflow_path.to_path_buf(),
source,
}
})?;
let mut actions = Vec::new();
match &parsed {
Parsed::Workflow(workflow) => {
for job in &workflow.jobs {
let job_id = JobId::from(job.id.clone());
extract_steps(
&job.steps,
workflow_rel_path,
|step| Slot::WorkflowStep {
job: job_id.clone(),
step,
},
&mut actions,
);
}
}
Parsed::Action(action) => {
extract_steps(
&action.steps,
workflow_rel_path,
|step| Slot::CompositeStep { step },
&mut actions,
);
}
}
Ok((parsed, actions))
}
pub fn scan_file(
&self,
workflow_path: &Path,
kind: FileKind,
) -> Result<crate::domain::file::actions::ActionSet, WorkflowError> {
let rel = self.rel_path(workflow_path);
let (_, actions) = Self::extract_workflow(workflow_path, &rel, kind)?;
let mut action_set = crate::domain::file::actions::ActionSet::new();
for action in &actions {
action_set.add(&action.uses_ref.interpret());
}
Ok(action_set)
}
fn located_from_file(
workflow_path: &Path,
workflow_rel_path: &WorkflowPath,
kind: FileKind,
) -> Result<Vec<crate::domain::file::actions::Located>, WorkflowError> {
let (_, actions) = Self::extract_workflow(workflow_path, workflow_rel_path, kind)
.map_err(WorkflowError::from)?;
Ok(actions
.into_iter()
.map(|action| crate::domain::file::actions::Located {
action: action.uses_ref.interpret(),
site: action.site,
origin: action.origin,
})
.collect())
}
}
impl crate::domain::file::scan::Scanner for FileScanner {
fn scan(
&self,
) -> Box<dyn Iterator<Item = Result<crate::domain::file::actions::Located, WorkflowError>> + '_>
{
type LocatedIter =
Box<dyn Iterator<Item = Result<crate::domain::file::actions::Located, WorkflowError>>>;
let files = match self.find_managed() {
Ok(w) => w,
Err(e) => return Box::new(std::iter::once(Err(e))),
};
Box::new(files.into_iter().flat_map(move |file| {
let rel = self.rel_path(&file.path);
match Self::located_from_file(&file.path, &rel, file.kind) {
Ok(actions) => {
let iter: LocatedIter = Box::new(actions.into_iter().map(Ok));
iter
}
Err(e) => Box::new(std::iter::once(Err(e))),
}
}))
}
fn scan_paths(&self) -> Box<dyn Iterator<Item = Result<PathBuf, WorkflowError>> + '_> {
match self.find_managed_paths() {
Ok(paths) => Box::new(paths.into_iter().map(Ok)),
Err(e) => Box::new(std::iter::once(Err(e))),
}
}
fn repo_rel(&self, path: &Path) -> WorkflowPath {
self.rel_path(path)
}
fn scan_all_with_parsed(
&self,
) -> Result<(Vec<crate::domain::file::actions::Located>, Vec<Parsed>), WorkflowError> {
let files = self.find_managed()?;
let mut located = Vec::new();
let mut parsed = Vec::new();
for file in files {
let rel = self.rel_path(&file.path);
let (p, actions) =
Self::extract_workflow(&file.path, &rel, file.kind).map_err(WorkflowError::from)?;
located.extend(
actions
.into_iter()
.map(|a| crate::domain::file::actions::Located {
action: a.uses_ref.interpret(),
site: a.site,
origin: a.origin,
}),
);
parsed.push(p);
}
Ok((located, parsed))
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
#[path = "tests.rs"]
mod tests;
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
#[path = "composite_tests.rs"]
mod composite_tests;