use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; use crate::db::{is_executable, read_extension, read_or_none}; use crate::backend::{Repo, backend}; use crate::log; /// Defines how tasks are selected to be run #[derive(Clone, Debug)] pub enum Select { /// Select all tasks (executable files) from a given basedir All(PathBuf), /// Select all tasks with a matching TASK.inbox from a basedir Inbox(PathBuf), /// Select all tasks from a basedir with a matching TASK.inbox /// in a separate inbox directory. InboxDir(PathBuf, String), /// Select all tasks from a basedir that are also in a list List(PathBuf, Vec), } impl Select { /// Removes the inbox file for the given task pub fn clean(&self, task: &str) { match self { Select::Inbox(basedir) => { std::fs::remove_file(&basedir.join(&format!("{}.inbox", task))).unwrap(); }, Select::InboxDir(_basedir, inboxdir) => { std::fs::remove_file(&PathBuf::from(inboxdir).join(&format!("{}.inbox", task))).unwrap() }, _ => {}, } } /// Find tasks matched by a selection pub fn apply(&self, context: &log::Context) -> Result, MissingTask> { // TODO: Dedicated error type for specific IO errors: // - missing basedir/select // - permissions problem for basedir/select match self { // We load all executable entries from basedir Select::All(basedir) => { Ok(from_dir(basedir, self.clone(), context)) }, // We load all entries ending with .inbox from basedir Select::Inbox(basedir) => { let inbox_entries = basedir.read_dir().unwrap().filter_map(|f| { if f.is_err() { // Dismiss individual file errors return None; } let f_string = f.unwrap().file_name().to_str().unwrap().to_string(); if ! f_string.ends_with(".inbox") || f_string.starts_with(".") { // We're only looking for non-hidden *.inbox files return None; } return Some(f_string.trim_end_matches(".inbox").to_string()); }).collect(); from_dir_and_list(basedir, inbox_entries, self.clone(), context) }, // We load all entries ending with .inbox from inboxdir Select::InboxDir(basedir, inboxdir) => { let inbox_entries = PathBuf::from(inboxdir).read_dir().unwrap().filter_map(|f| { if f.is_err() { // Dismiss individual file errors return None; } let f_string = f.unwrap().file_name().to_str().unwrap().to_string(); if ! f_string.ends_with(".inbox") || f_string.starts_with(".") { // We're only looking for non-hidden *.inbox files return None; } return Some(f_string.trim_end_matches(".inbox").to_string()); }).collect(); from_dir_and_list(basedir, inbox_entries, self.clone(), context) }, // Load all entries from list Select::List(basedir, list) => { from_dir_and_list(basedir, list.clone(), self.clone(), context) }, } } } // TODO: Maybe all source/DVCS information should be moved to Repo // so that task structure is simpler. #[derive(Debug)] pub struct Task { /// The filename for the task pub name: String, /// Full path to the task pub bin: PathBuf, /// Potentially, a source repository to track for updates pub source: Option, /// The full Repo information pub repo: Option, /// Variables for task config pub config: HashMap, /// Potentially, a branch/commit to track pub branch: Option, /// List of hosts on which this task should run pub hosts: Vec, /// Whether the source, if any, has been cloned already pub cloned: bool, /// Whether to track subrepository/submodule updates pub subupdates: bool, /// The context in which to store variables for translations pub context: log::Context, /// The selection context in which a task was created, so that running it can remove it from inbox pub select: Select, } /// config returns an option of (settings directory, ignored tasks) as /// (PathBuf, Vec) pub fn config(basedir: &Path) -> (PathBuf, Vec) { let hostname = std::env::var("HOST").unwrap_or_else(|_| hostname::get().unwrap().into_string().unwrap()); let path = basedir.join(hostname); if path.is_dir() { let ignored = path .read_dir() .unwrap() .filter_map(|x| { if x.is_err() { return None; } let name = x.unwrap().file_name().into_string().unwrap(); if name.ends_with(".ignore") { return Some(name.trim_end_matches(".ignore").to_string()); } return None; }) .collect(); (path, ignored) } else { // TODO: load .ignore in default config? (basedir.join("config"), Vec::new()) } } impl Task { pub fn from_path(path: &Path, select: Select, context: &log::Context) -> Option { let name = path.file_name().unwrap().to_str().unwrap().to_string(); // We don't return a task if: // - the path is not a file // - the file is not executable (can't be run) // - the file starts with . (hidden file) if !path.is_file() || !is_executable(&path) || name.starts_with('.') { return None; } let basedir = path.parent().unwrap(); // Calling a task in / (FS root) will panic let source = read_extension(path, "source"); let dest = source_dir_from_basedir(&basedir, &name); let cloned = source.clone().map_or(false, |_| dest.is_dir()); let subupdates = read_extension(path, "subupdates").is_some(); let checkout = read_extension(path, "checkout"); // Copy the global context so we have a more local scope let mut context = context.clone(); context.insert("$i18n_task".to_string(), name.clone()); if let Some(branch) = &checkout { context.insert("$i18n_branch".to_string(), branch.to_string()); } if let Some(source_url) = &source { context.insert("$i18n_source".to_string(), source_url.clone()); } let dvcs = if let Some(dvcs) = backend(read_extension(path, "dvcs")) { dvcs } else { // TODO: forgebuild error message eprintln!("Unrecognized dvcs for task {}, possible options are 'git' or 'mercurial'. Skipped", &name); return None; }; Some(Task { name, bin: path.to_path_buf(), // None source = None repo repo: source.as_ref().map(|s| { Repo::new( dvcs, s, &dest, subupdates, ) }), source, config: HashMap::new(), branch: checkout, hosts: read_extension(path, "hosts").map_or(Vec::new(), |c| { c.split("\n").map(|line| line.to_string()).collect() }), cloned, subupdates: read_extension(path, "subupdates").is_some(), context, select, }) } pub fn checkout(&self) { if let Some(branch) = &self.branch { if let Some(repo) = &self.repo { self.info("to_branch"); self.debug("checkout"); repo.checkout(branch); } } } pub fn run_on_host(&self) -> bool { if self.hosts.len() == 0 { return true; } // $HOSTNAME env is a bashism, we need to call libc (through hostname crate) // to find out the actual hostname let hostname = std::env::var("HOST") .unwrap_or_else(|_| hostname::get().unwrap().into_string().unwrap()); if self.hosts.contains(&hostname) { return true; } return false; } pub fn update_and_run(&self, force: &bool) { if let Some(repo) = &self.repo { if repo.update() { self.run(); } else if *force { self.debug("forcing"); self.run(); } else { self.debug("no_update"); } } else { unreachable!("this function should never be called on a task whcih doesnt have a repo"); } } pub fn run(&self) { if !self.run_on_host() { // TODO: Skip host debug? return; } self.info("run"); // TODO: debug message for removing inbox self.select.clean(&self.name); let cmd_out = Command::new("bash") // TODO: no need to call bash? .arg(&self.bin) .arg(&self.name) .output() .expect(&format!("Failed to run {:?}", &self.bin)); let mut log_path = self.bin.clone(); log_path.set_extension("log"); std::fs::write(&log_path, cmd_out.stderr) .expect(&format!("Failed to write log to {:?}", &log_path)); } pub fn run_once(&self) { if !self.run_on_host() { return; } let mut done_path = self.bin.clone(); done_path.set_extension("done"); if !done_path.exists() { self.run(); std::fs::write(&done_path, "").expect("Failed to register task as done"); } } #[allow(dead_code)] pub fn debug(&self, message: &str) { log::debug(message, &self.context); } #[allow(dead_code)] pub fn info(&self, message: &str) { log::info(message, &self.context); } #[allow(dead_code)] pub fn warn(&self, message: &str) { log::warn(message, &self.context); } #[allow(dead_code)] pub fn error(&self, message: &str) { log::error(message, &self.context); } } pub struct MissingTask(pub String); /// Contains a mapping of sources to their corresponding tasks pub struct SourceSet { mapping: HashMap>, } impl SourceSet { /// Loads a SourceSet from a basedir pub fn from(basedir: &Path) -> Result { let source_urls = basedir.read_dir()?.filter_map(|p| { if p.is_err() { return None; } // Skip individual errors let p = p.unwrap().path(); let path_str = p.to_str().unwrap(); if !path_str.ends_with(".source") { // Filter out non-source files return None; } return Some(( path_str.trim_end_matches(".source").to_string(), // Task name read_or_none(&p).unwrap() // Source URL )); }); let mut sources_map: HashMap> = HashMap::new(); for (task, source) in source_urls { if let Some(list) = sources_map.get_mut(&source) { list.push(task.to_string()); } else { sources_map.insert(source.clone(), vec!(task.to_string())); } } Ok(SourceSet { mapping: sources_map }) } /// Returns the task names associated with a given source pub fn tasks_for(&self, source: &str) -> Option> { self.mapping.get(source).map(|x| x.clone()) } } /// Loads a task list from a given base directory, taking only tasks that are in requested list. /// Given tasks can be either a task name or a task URL. This function will panic if the basedir /// does not exist, or error if a requested task/source does not exist. pub fn from_dir_and_list(basedir: &Path, list: Vec, select: Select, context: &log::Context) -> Result, MissingTask> { // TODO: Write tests for permissions problems // If we're looking up specific tasks, maybe they're referenced by source // and not by name. SourceSet allows for a source->name mapping. let sourceset = SourceSet::from(basedir).unwrap(); let mut tasks = Vec::new(); for t in list { if let Some(task) = Task::from_path(&basedir.join(&t), select.clone(), context) { tasks.push(task); } else { // Maybe it's not a task name, but a task URL? if let Some(list) = sourceset.tasks_for(&t) { // Hopefully safe unwrap (unless there's a source without a corresponding task?) let task_list = list.iter().map(|t_name| Task::from_path(&basedir.join(&t_name), select.clone(), context).unwrap()); tasks.extend(task_list); } else { return Err(MissingTask(t)); } } } Ok(tasks) } /// Loads a task list from a given base directory. Fails if the directory /// is not readable with std::io::Error. pub fn from_dir(basedir: &Path, select: Select, context: &log::Context) -> Vec { basedir.read_dir().unwrap().filter_map(|f| { if f.is_err() { // Dismiss individual file errors return None; } return Task::from_path(&f.unwrap().path(), select.clone(), context); }).collect() } /// Takes a &Path to a basedir, and a &str task_name, and return /// the corresponding source directory as a PathBuf. Does not check /// if the target exists. pub fn source_dir_from_basedir(basedir: &Path, task_name: &str) -> PathBuf { basedir.join(&format!(".{}", task_name)) }