rusht 1.3.0

Shell commands written in Rust
Documentation
use ::std::env;
use ::std::env::VarError;
use ::std::fs::create_dir_all;
use ::std::fs::OpenOptions;
use ::std::io::BufReader;
use ::std::io::Write;
use ::std::path::Path;
use ::std::path::PathBuf;
use ::std::time::Duration;

use ::log::debug;
use ::serde::Deserialize;
use ::serde::Serialize;
use ::time::OffsetDateTime;

use crate::cached::args::CachedKeyArgs;
use crate::cached::CachedArgs;
use crate::common::fail;
use crate::common::file_modified_time_in_seconds;
use crate::common::git::{git_head_ref, git_stripped_diff};
use crate::common::git::git_master_base_ref;
use crate::common::git::git_repo_dir;
use crate::common::git::git_uncommitted_changes;
use crate::common::LineWriter;
use crate::common::safe_filename;
use crate::common::Task;
use crate::common::TeeWriter;
use crate::common::unique_filename;
use crate::common::VecWriter;
use crate::ExitStatus;

pub const DATA_VERSION: u32 = 1;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheStatus {
    RanSuccessfully,
    FromCache(String),
    Failed(ExitStatus),
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Cache {
    time: OffsetDateTime,
    task: Task,
    output: String,
}

pub async fn cached(args: CachedArgs, writer: &mut impl LineWriter) -> Result<CacheStatus, String> {
    let task = args.cmd.clone().into_task();
    let cache_pth = get_cache_path(&args, &task).await?;
    eprintln!("cache path: {}", cache_pth.to_string_lossy());
    let cached_output = try_read_cache(&args.duration, &cache_pth);
    if let Some(output) = cached_output {
        return Ok(CacheStatus::FromCache(output));
    }
    let mut vec_writer = VecWriter::new();
    let mut tee_writer = TeeWriter::new(writer, &mut vec_writer);
    let exit_code = task
        .execute_with_stdout(args.verbose, &mut tee_writer)
        .await;
    if exit_code.is_err() {
        return Ok(CacheStatus::Failed(exit_code));
    }
    let output = vec_writer.get().join("\n");
    update_cache(output, task, &cache_pth);
    Ok(CacheStatus::RanSuccessfully)
}

fn try_read_cache(max_age: &Duration, cache_pth: &Path) -> Option<String> {
    let cache = OpenOptions::new()
        .read(true)
        .open(cache_pth)
        .map(BufReader::new)
        .map(serde_json::from_reader::<_, Cache>);
    match cache {
        Ok(Ok(cache)) => {
            debug!(
                "found cached entry from {} at {}",
                &cache.time,
                cache_pth.to_string_lossy()
            );
            let age = OffsetDateTime::now_utc() - cache.time;
            if &age > max_age {
                debug!(
                    "cached entry is too old, {}s > {}s",
                    &age.whole_seconds(),
                    &max_age.as_secs()
                );
            } else {
                debug!(
                    "valid cache ({}s); was created with task: {}",
                    age.whole_seconds(),
                    cache.task.as_str()
                );
                return Some(cache.output);
            }
        }
        Ok(Err(_)) => {
            fail("failed to parse cache file");
        }
        Err(_) => {
            debug!("no cached entry at {}", cache_pth.to_string_lossy());
        }
    }
    None
}

fn update_cache(output: String, task: Task, cache_pth: &Path) {
    let mut cache_file = OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .open(cache_pth)
        .unwrap_or_else(|err| {
            panic!(
                "failed to create/open cache file at {}, err {}",
                cache_pth.to_string_lossy(),
                err
            )
        });
    let cache = Cache {
        time: OffsetDateTime::now_utc(),
        task,
        output,
    };
    let cache_json = serde_json::to_string(&cache).expect("failed to serialize cache");
    debug!(
        "writing output ({} bytes json) to cache at {}",
        cache_json.len(),
        cache_pth.to_string_lossy()
    );
    cache_file
        .write_all(cache_json.as_bytes())
        .unwrap_or_else(|err| {
            panic!(
                "failed to write to cache file at {}, err {}",
                cache_pth.to_string_lossy(),
                err
            )
        });
}

async fn get_cache_path(args: &CachedArgs, task: &Task) -> Result<PathBuf, String> {
    let key = build_key(args, task).await?;
    let filename = unique_filename(&key);
    let mut pth = dirs::cache_dir().expect("failed to find cache directory");
    pth.push(format!("cmdcache_v{}", DATA_VERSION));
    create_dir_all(&pth).unwrap();
    pth.push(filename);
    debug!("created cache path {} from args {:?}", pth.to_string_lossy(), args);
    Ok(pth)
}

async fn build_key(args: &CachedArgs, task: &Task) -> Result<String, String> {
    build_key_with(&args.key, task, read_from_sys_env).await
}

fn read_from_sys_env(env_key: &str) -> Result<String, String> {
    Ok(match env::var(env_key) {
        Ok(val) => format!("{env_key}-{val}"),
        Err(VarError::NotPresent) => format!("{env_key}_NO"),
        Err(VarError::NotUnicode(_)) =>
            return Err(format!("cannot cache env '{env_key}' because value is not unicode")),
    })
}

async fn build_key_with(
    args: &CachedKeyArgs,
    task: &Task,
    get_from_env: impl Fn(&str) -> Result<String, String>
) -> Result<String, String> {
    debug_assert!(args.env.is_sorted());
    debug_assert!(args.env.is_sorted());
    let mut key: Vec<String> = Vec::new();
    if ! args.no_dir {
        key.push(task.working_dir.to_string_lossy().into_owned())
    }
    if ! args.no_command {
        key.push(task.as_cmd_str())
    }
    if ! args.no_direct_env {
        for (env_key, value) in &task.extra_envs {
            key.push(format!("{}-{}", env_key, value))
        }
    }
    if args.git_head_diff {
        key.push(git_stripped_diff(&task.working_dir, "HEAD").await?)
    }
    if args.git_head {
        let head = git_head_ref(&task.working_dir).map_err(|err| {
            format!("caching based on git HEAD, but could not read it, err: {err}") })?;
        key.push(head)
    } else if args.git_base {
        let head = git_master_base_ref(&task.working_dir).await.map_err(|err| {
            format!("caching based on git merge base, but could not determine it, err: {err}") })?;
        key.push(head)
    }
    if args.git_repo_dir {
        key.push(git_repo_dir(&task.working_dir).await?)
    }
    if args.git_pending {
        add_pending_file_timestamps(&task, &mut key).await?;
    }
    for env_key in &args.env {
        key.push(get_from_env(env_key)?)
    }
    for text in &args.text {
        key.push(text.to_owned())
    }
    Ok(unique_filename(&key.join("_")))
}

async fn add_pending_file_timestamps(task: &&Task, key: &mut Vec<String>) -> Result<(), String> {
    let mut pending = git_uncommitted_changes(&task.working_dir).await.map_err(|err| {
        format!("caching based on pending git changes, but could not query them, err: {err}")
    })?;
    pending.sort();  // perhaps unnecessary, but just in case git changes order
    let mut with_ts = pending.join("_");
    for file in pending {
        let mod_ts = file_modified_time_in_seconds(&file).await.unwrap_or(1);
        with_ts.push_str(&format!("_{mod_ts}"))
    }
    key.push(safe_filename(&with_ts));
    Ok(())
}

#[cfg(test)]
mod tests {
    use ::std::collections::HashMap;

    use super::*;

    fn create_test_task() -> Task {
        Task {
            cmd: "ls".to_owned(),
            args: vec!["-a".to_owned()],
            working_dir: PathBuf::from("/tmp"),
            extra_envs: HashMap::new(),
            stdin: None,
        }
    }

    fn read_from_test_env(env_key: &str) -> Result<String, String> {
        Ok(match env_key {
            "KEY" => "ENV_VALUE".to_owned(),
            other_key => format!("{other_key}_NO"),
        })
    }

    #[async_std::test]
    async fn build_key_vanilla() {
        let task = create_test_task();
        let args = CachedArgs::default();
        let key = build_key_with(&args.key, &task, read_from_test_env).await;
        assert_eq!(key, Ok("tmp_ls_a_qjtza8xbfyol".to_owned()));
    }

    #[async_std::test]
    async fn build_key_with_text_env() {
        let task = create_test_task();
        let args = CachedArgs {
            key: CachedKeyArgs {
                text: vec!["hello".to_owned(), "world".to_owned()],
                env: vec!["VAR".to_owned()],
                ..Default::default()
            },
            ..Default::default()
        };
        let key = build_key_with(&args.key, &task, read_from_test_env).await;
        assert_eq!(key, Ok("tmp_ls_a_VAR_NO_hellq1kzva1h4vlt".to_owned()));
    }
}