<commit_before>use eyre::{bail, Result};
use regex::Regex;
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::{collections::{BTreeMap, HashMap}, str::FromStr};
use std::{
    fs,
    path::{Path, PathBuf},
};
use strum::{EnumString, EnumVariantNames, ToString, VariantNames};

/// # A file or directory within a project
#[skip_serializing_none]
#[derive(Debug, Default, Clone, JsonSchema, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct File {
    /// The relative path of the file within the project folder
    path: String,

    /// Whether the entry is a directory or not
    is_dir: bool,

    /// The media type (aka MIME type) of the file
    media_type: Option<String>,

    /// The SHA1 hash of the contents of the file
    sha1: Option<String>,
}

impl File {
    pub fn from_path(folder: &Path, path: &Path) -> (PathBuf, File) {
        let canonical_path = path.canonicalize().expect("Unable to canonicalize path");
        let relative_path = path
            .strip_prefix(folder)
            .expect("Unable to strip prefix")
            .display()
            .to_string();

        let media_type = mime_guess::from_path(path)
            .first()
            .map(|mime| mime.essence_str().to_string());

        let file = File {
            path: relative_path,
            is_dir: path.is_dir(),
            media_type,
            ..Default::default()
        };

        (canonical_path, file)
    }
}

/// # Details of a project
///
/// An implementation, and extension, of schema.org [`Project`](https://schema.org/Project).
/// Uses schema.org properties where possible but adds extension properties
/// where needed (e.g. `theme`).
#[skip_serializing_none]
#[derive(Debug, Default, Clone, JsonSchema, Deserialize, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Project {
    /// The name of the project
    name: Option<String>,

    /// A description of the project
    description: Option<String>,

    /// URL of the image to be used when displaying the project
    ///
    /// If not specified, will default to the most recently
    /// modified image in the project (if any).
    image: Option<String>,

    /// The path (within the project) of the project's main file
    ///
    /// If not specified, will default to the first file matching the
    /// the regular expression in the configuration settings.
    main: Option<String>,

    /// The default theme to use when viewing documents in this project
    ///
    /// If not specified, will default to the default theme in the
    /// configuration settings.
    theme: Option<String>,

    /// The files in the project directory
    files: BTreeMap<PathBuf, File>,
}

/// A format to display a plugin using
#[derive(Debug, EnumString, EnumVariantNames, PartialEq, ToString)]
#[strum(serialize_all = "lowercase")]
pub enum ShowFormat {
    Md,
    Toml,
    Json,
}

impl Project {
    /// The name of the project's manifest file within the project directory
    const FILE_NAME: &'static str = "project.json";

    /// Get the JSON Schema for a project
    pub fn schema() -> String {
        let schema = schema_for!(Project);
        serde_json::to_string_pretty(&schema).unwrap()
    }

    /// Get the path to a projects' manifest file
    fn file(folder: &str) -> PathBuf {
        Path::new(folder).join(Project::FILE_NAME)
    }

    /// Read a project's manifest file
    ///
    /// If there is no manifest file, then return a default project
    fn read(folder: &str) -> Result<Project> {
        if !Path::new(folder).exists() {
            bail!("Project folder does not exist: {}", folder)
        }

        let file = Project::file(folder);
        let project = if file.exists() {
            let json = fs::read_to_string(file)?;
            serde_json::from_str(&json)?
        } else {
            Project::default()
        };

        Ok(project)
    }

    /// Write a project's manifest file
    ///
    /// If the project folder does not exist yet then it will be created
    pub fn write(folder: &str, project: &Project) -> Result<()> {
        fs::create_dir_all(folder)?;

        let file = Project::file(folder);
        let json = serde_json::to_string_pretty(project)?;
        fs::write(file, json)?;

        Ok(())
    }

    /// Initialize a project in a new, or existing, folder
    ///
    /// If the project has already been initialized (i.e. has a manifest file)
    /// then this function will do nothing
    pub fn init(folder: &str) -> Result<Project> {
        if Project::file(folder).exists() {
            return Project::read(folder);
        }

        let project = Project::default();
        Project::write(folder, &project)?;

        Ok(project)
    }

    /// Load a project including creating default values for properties
    /// where necessary
    pub fn load(folder: &str, config: &config::ProjectsConfig) -> Result<Project> {
        let project = Project::read(folder)?;
        let Project {
            name, main, theme, ..
        } = project;

        // Name defaults to the name of the folder
        let name = name.or_else(|| match Path::new(folder).components().last() {
            Some(last) => Some(last.as_os_str().to_string_lossy().to_string()),
            None => Some("Unnamed".to_string()),
        });

        // Get all the files in the project (use `min_depth` to ignore the folder itself)
        let folder = Path::new(folder);
        let files: Vec<(PathBuf, File)> = walkdir::WalkDir::new(folder).min_depth(1)
            .into_iter()
            .filter_map(|entry| {
                let entry = match entry.ok() {
                    Some(entry) => entry,
                    None => return None,
                };
                Some(File::from_path(folder, entry.path()))
            })
            .collect();

        // Main defaults to the first file that matches configured patterns (if any)
        let main = main.or_else(|| {
            // See if there are any files matching patterns
            for pattern in &config.main_patterns {
                let re = match Regex::new(&pattern.to_lowercase()) {
                    Ok(re) => re,
                    Err(_) => {<commit_msg>feat(Files): Add modified and size properties<commit_after>use eyre::{bail, Result};
use regex::Regex;
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::{
    collections::{BTreeMap, HashMap},
    str::FromStr,
    time::{UNIX_EPOCH},
};
use std::{
    fs,
    path::{Path, PathBuf},
};
use strum::{EnumString, EnumVariantNames, ToString, VariantNames};

/// # A file or directory within a project
#[skip_serializing_none]
#[derive(Debug, Default, Clone, JsonSchema, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct File {
    /// The relative path of the file within the project folder
    path: String,

    /// Whether the entry is a directory or not
    is_dir: bool,

    /// Time that the file was last modified (seconds since Unix Epoch)
    modified: Option<u64>,

    /// Size of the file in bytes
    size: Option<u64>,

    /// The media type (aka MIME type) of the file
    media_type: Option<String>,

    /// The SHA1 hash of the contents of the file
    sha1: Option<String>,
}

impl File {
    pub fn from_path(folder: &Path, path: &Path) -> (PathBuf, File) {
        let canonical_path = path.canonicalize().expect("Unable to canonicalize path");
        let relative_path = path
            .strip_prefix(folder)
            .expect("Unable to strip prefix")
            .display()
            .to_string();

        let (modified, size) = match path.metadata() {
            Ok(metadata) => {
                let modified = metadata
                    .modified()
                    .ok()
                    .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
                    .and_then(|duration| Some(duration.as_secs()));
                let size = Some(metadata.len());
                (modified, size)
            }
            Err(_) => (None, None),
        };

        let media_type = mime_guess::from_path(path)
            .first()
            .map(|mime| mime.essence_str().to_string());

        let file = File {
            path: relative_path,
            is_dir: path.is_dir(),
            modified,
            size,
            media_type,
            ..Default::default()
        };

        (canonical_path, file)
    }
}

/// # Details of a project
///
/// An implementation, and extension, of schema.org [`Project`](https://schema.org/Project).
/// Uses schema.org properties where possible but adds extension properties
/// where needed (e.g. `theme`).
#[skip_serializing_none]
#[derive(Debug, Default, Clone, JsonSchema, Deserialize, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Project {
    /// The name of the project
    name: Option<String>,

    /// A description of the project
    description: Option<String>,

    /// URL of the image to be used when displaying the project
    ///
    /// If not specified, will default to the most recently
    /// modified image in the project (if any).
    image: Option<String>,

    /// The path (within the project) of the project's main file
    ///
    /// If not specified, will default to the first file matching the
    /// the regular expression in the configuration settings.
    main: Option<String>,

    /// The default theme to use when viewing documents in this project
    ///
    /// If not specified, will default to the default theme in the
    /// configuration settings.
    theme: Option<String>,

    /// The files in the project directory
    files: BTreeMap<PathBuf, File>,
}

/// A format to display a plugin using
#[derive(Debug, EnumString, EnumVariantNames, PartialEq, ToString)]
#[strum(serialize_all = "lowercase")]
pub enum ShowFormat {
    Md,
    Toml,
    Json,
}

impl Project {
    /// The name of the project's manifest file within the project directory
    const FILE_NAME: &'static str = "project.json";

    /// Get the JSON Schema for a project
    pub fn schema() -> String {
        let schema = schema_for!(Project);
        serde_json::to_string_pretty(&schema).unwrap()
    }

    /// Get the path to a projects' manifest file
    fn file(folder: &str) -> PathBuf {
        Path::new(folder).join(Project::FILE_NAME)
    }

    /// Read a project's manifest file
    ///
    /// If there is no manifest file, then return a default project
    fn read(folder: &str) -> Result<Project> {
        if !Path::new(folder).exists() {
            bail!("Project folder does not exist: {}", folder)
        }

        let file = Project::file(folder);
        let project = if file.exists() {
            let json = fs::read_to_string(file)?;
            serde_json::from_str(&json)?
        } else {
            Project::default()
        };

        Ok(project)
    }

    /// Write a project's manifest file
    ///
    /// If the project folder does not exist yet then it will be created
    pub fn write(folder: &str, project: &Project) -> Result<()> {
        fs::create_dir_all(folder)?;

        let file = Project::file(folder);
        let json = serde_json::to_string_pretty(project)?;
        fs::write(file, json)?;

        Ok(())
    }

    /// Initialize a project in a new, or existing, folder
    ///
    /// If the project has already been initialized (i.e. has a manifest file)
    /// then this function will do nothing
    pub fn init(folder: &str) -> Result<Project> {
        if Project::file(folder).exists() {
            return Project::read(folder);
        }

        let project = Project::default();
        Project::write(folder, &project)?;

        Ok(project)
    }

    /// Load a project including creating default values for properties
    /// where necessary
    pub fn load(folder: &str, config: &config::ProjectsConfig) -> Result<Project> {
        let project = Project::read(folder)?;
        let Project {
            name, main, theme, ..
        } = project;

        // Name defaults to the name of the folder
        let name = name.or_else(|| match Path::new(folder).components().last() {
            Some(last) => Some(last.as_os_str().to_string_lossy().to_string()),
            None => Some("Unnamed".to_string()),
        });

        // Get all the files in the project (use `min_depth` to ignore the folder itself)
        let folder = Path::new(folder);
        let files: Vec<(PathBuf, File)> = walkdir::WalkDir::new(folder)
            .min_depth(1)
            .into_iter()
            .filter_map(|entry| {
                let entry = match entry.ok() {
                    Some(entry) => entry,
                    None => return None,
                };
                Some(File::from_path(folder, entry.path()))
            })
            .collect();

        // Main defaults to the first file that matches configured patterns (if any)
        let main = main.or_else(|| {
            // See if there are any files matching patterns
            for pattern in &config.main_patterns {
                let re = match Regex::new(&pattern.to_lowercase()) {
                    Ok(re) => re,
                    Err(_) => {
{'fraction_non_alphanumeric': 0.12580693589551117, 'fraction_numerical': 0.0007506380423359856, 'mean_word_length': 2.217338807051437, 'pattern_counts': {'":': 0, '<': 31, '<?xml version=': 0, '>': 59, 'https://': 2, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '6052019', 'n_tokens_mistral': 3719, 'n_tokens_neox': 3677, 'n_words': 1206}