topcat 0.1.0

A tool for concatenating files in topological order
use std::env;
use std::fs::File;
use std::io::Write;
use std::path::Path;

use log::info;

use crate::config::Config;
use crate::exceptions::TopCatError;
use crate::file_dag::TCGraph;
use crate::fs::FileSystem;

/// Append a string to the end of the file content.
///
/// Trim the whitespaces at the end of the file content and append the given string if it
/// does not already end with it. Finally, add a newline character at the end of the content.
///
/// This is handy when joining sql files, where the last statement should end with a semicolon,
/// but when working on individual files, it's easy to forget to add it. But if it's not there,
/// it will cause an error when trying to execute the concatenated file.
///
/// # Arguments
///
/// * `file_content` - The original content of the file.
/// * `append_str` - The string to append to the file content.
///
/// # Returns
///
/// The new file content with the string appended.
///
/// # Example
///
/// ```rust
/// let file_content = "Hello, world!";
/// let append_str = ", goodbye!";
///
/// let new_content = append_string_to_file_content(file_content.to_string(), append_str);
///
/// assert_eq!(new_content, "Hello, world!, goodbye!\n")
/// ```
fn append_string_to_file_content(file_content: String, append_str: &str) -> String {
    let mut content = file_content.trim_end().to_string();

    if !content.ends_with(append_str) {
        content.push_str(append_str);
    }

    content.push('\n');
    content
}

trait OutputDestination {
    fn write_line(&mut self, content: &str) -> std::io::Result<()>;
    fn write_str(&mut self, content: &str) -> std::io::Result<()>;
}

struct FileOutput {
    writer: Box<dyn Write>,
}

impl FileOutput {
    fn new(file_path: &Path) -> Result<Self, TopCatError> {
        let file = File::create(file_path)?;
        Ok(Self {
            writer: Box::new(file),
        })
    }
}

impl OutputDestination for FileOutput {
    fn write_line(&mut self, content: &str) -> std::io::Result<()> {
        writeln!(self.writer, "{}", content)
    }

    fn write_str(&mut self, content: &str) -> std::io::Result<()> {
        write!(self.writer, "{}", content)
    }
}

struct ConsoleOutput;

impl OutputDestination for ConsoleOutput {
    fn write_line(&mut self, content: &str) -> std::io::Result<()> {
        println!("{}", content);
        Ok(())
    }

    fn write_str(&mut self, content: &str) -> std::io::Result<()> {
        print!("{}", content);
        Ok(())
    }
}

/// Generate output based on the given graph and configuration.
///
/// # Arguments
///
/// * `graph` - The TCGraph representing the files and their dependencies.
/// * `config` - The configuration settings.
/// * `fs` - The file system to read from and write to.
///
/// # Returns
///
/// Returns `Ok(())` if the generation is successful, otherwise returns a `TopCatError`.
pub fn generate(
    graph: TCGraph,
    config: Config,
    fs: &mut dyn FileSystem,
) -> Result<(), TopCatError> {
    info!("Generating output");

    let mut output_dest: Box<dyn OutputDestination>;
    if config.dry_run {
        info!("Dry run enabled, not writing to file");
        output_dest = Box::new(ConsoleOutput {});
    } else {
        output_dest = Box::new(FileOutput::new(&config.output)?);
    }

    let command = env::args().skip(1).collect::<Vec<_>>().join(" ");
    output_dest.write_line(&format!(
        "{0} This file was generated by topcat. To regenerate run:\n{0}\n{0} topcat {1}\n",
        config.comment_str, command
    ))?;
    let file_sep_str = config.file_separator_str.as_str();

    for file_name in graph.get_sorted_files()? {
        let contents = fs.read_to_string(&file_name)?;

        output_dest.write_line(file_sep_str)?;
        output_dest
            .write_line(format!("{} {}", config.comment_str, file_name.display()).as_str())?;
        output_dest.write_str(&append_string_to_file_content(
            contents,
            &config.file_end_str,
        ))?;
        output_dest.write_line("")?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_ensure_file_separator() {
        let d = append_string_to_file_content("SELECT 1 FROM table".to_string(), ";");
        assert_eq!(d, "SELECT 1 FROM table;\n");

        let d = append_string_to_file_content("SELECT 1 FROM table;".to_string(), ";");
        assert_eq!(d, "SELECT 1 FROM table;\n");

        let d = append_string_to_file_content("SELECT 1 FROM table;\n\n\n".to_string(), "\\");
        assert_eq!(d, "SELECT 1 FROM table;\n");
    }
}