[
  {
    "path": ".gitignore",
    "content": "/target\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"terse-cli-workspace\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nterse_cli_lib = { path = \"./terse_cli_lib\" }\nquote = \"1.0.37\"\npretty_assertions = \"1.4.1\"\n\n[workspace]\nmembers = [\"terse_cli_lib\", \"terse_cli\", \"example\"]\n"
  },
  {
    "path": "LICENSE.md",
    "content": "MIT License\n\nCopyright (c) 2024 Jeffray Zhang\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "example/Cargo.toml",
    "content": "[package]\nname = \"example\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nclap = { version = \"4.5.21\", features = [\"derive\", \"unstable-doc\"] }\nterse_cli = { path = \"../terse_cli\" }\n\n[[bin]]\nname = \"cli\"\npath = \"main.rs\"\n"
  },
  {
    "path": "example/main.rs",
    "content": "use clap::Parser;\nuse terse_cli::{command, subcommands};\n\n/// Example: cargo run -- command-one --a 3\n#[command]\nfn command_one(a: i32, b: Option<i32>) -> i32 {\n    a + b.unwrap_or(0)\n}\n\n/// Example: cargo run -- my-subcommands command-two --name Bob\n#[command]\nfn command_two(name: String) -> String {\n    format!(\"hello {}\", name)\n}\n\n/// Example: cargo run -- my-subcommands command-three --a 7 --b 3\n#[command]\nfn command_three(a: i32, b: i32) -> String {\n    format!(\"the difference is {}\", a - b)\n}\n\n/// Example: cargo run -- my-subcommands command-four\n#[command]\nfn command_four() -> String {\n    \"command four\".to_string()\n}\n\nsubcommands!(\n    /// These are the subcommands\n    my_subcommands, [command_two, command_three, command_four]);\n\nsubcommands!(\n    /// Example docs for the \"root\"\n    cli, [command_one, my_subcommands]);\n\nfn main() {\n    cli::run(cli::Args::parse());\n}\n\n// you can also use `--help` as you would expect\n// Example: cargo run -- my-subcommands --help\n"
  },
  {
    "path": "readme.md",
    "content": "\n# Terse CLI\n\nA wrapper around [clap](https://github.com/clap-rs/clap) that lets you build CLI applications with very little boilerplate code.\n\nModeled after [tiangolo's typer python library](https://github.com/fastapi/typer), you simply define your commands and subcommands as functions and annotate them with the `#[command]` attribute.\n\n## Current Status: Alpha\n\nThis is a work in progress. The core functionality is implemented, but if you want any customization on how your CLI is used (e.g. positional arguments, custom help messages, etc.) those things are not yet supported.\n\nKnown issues:\n- [ ] every command must have a return type that implements `Display`\n- [ ] positional arguments are not yet supported\n- [ ] argument docs are not yet supported\n\n## Installation\n\nInstall both clap and terse_cli:\n\n```sh\n$ cargo add clap --features derive\n$ cargo add terse_cli\n```\n\n## Example\n\nBelow snippet is from [./example/main.rs](./example/main.rs).\n\n```rs\nuse clap::Parser;\nuse terse_cli::{command, subcommands};\n\n/// Example: cargo run -- command-one --a 3\n#[command]\nfn command_one(a: i32, b: Option<i32>) -> i32 {\n    a + b.unwrap_or(0)\n}\n\n/// Example: cargo run -- my-subcommands command-two --name Bob\n#[command]\nfn command_two(name: String) -> String {\n    format!(\"hello {}\", name)\n}\n\n/// Example: cargo run -- my-subcommands command-three --a 7 --b 3\n#[command]\nfn command_three(a: i32, b: i32) -> String {\n    format!(\"the difference is {}\", a - b)\n}\n\n/// Example: cargo run -- my-subcommands command-four\n#[command]\nfn command_four() -> String {\n    \"command four\".to_string()\n}\n\nsubcommands!(\n    /// These are the subcommands\n    my_subcommands, [command_two, command_three, command_four]);\n\nsubcommands!(\n    /// Example docs for the \"root\"\n    cli, [command_one, my_subcommands]);\n\nfn main() {\n    cli::run(cli::Args::parse());\n}\n\n// you can also use `--help` as you would expect\n// Example: cargo run -- my-subcommands --help\n\n```\n"
  },
  {
    "path": "src/main.rs",
    "content": "fn main() {\n    println!(\"workspace root, this doesn't do anything!\");\n}\n"
  },
  {
    "path": "terse_cli/Cargo.toml",
    "content": "[package]\nname = \"terse_cli\"\nversion = \"0.1.4\"\nauthors = [\"David McNamee <david@mcnamee.io>\", \"Jeffray Zhang <zhangjeffray@gmail.com>\"]\nedition = \"2021\"\ndescription = \"A library for building no-boilerplate CLI apps\"\nlicense = \"MIT\"\nrepository = \"https://github.com/JeffrayZhang/terse-cli\"\nhomepage = \"https://github.com/JeffrayZhang/terse-cli\"\nreadme = \"../readme.md\"\n\n[dependencies]\nproc-macro2 = \"1.0.92\"\nterse_cli_lib = { path = \"../terse_cli_lib\", version = \"0.1.2\" }\n\n[lib]\nproc-macro = true\npath = \"lib.rs\"\n"
  },
  {
    "path": "terse_cli/lib.rs",
    "content": "extern crate proc_macro;\nuse proc_macro::TokenStream;\n\n#[proc_macro_attribute]\npub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream {\n    let _attr = proc_macro2::TokenStream::from(_attr);\n    let item = proc_macro2::TokenStream::from(item);\n\n    terse_cli_lib::command(_attr, item).unwrap().into()\n}\n\n#[proc_macro]\npub fn subcommands(item: TokenStream) -> TokenStream {\n    terse_cli_lib::subcommands(proc_macro2::TokenStream::from(item))\n        .unwrap()\n        .into()\n}\n"
  },
  {
    "path": "terse_cli_lib/Cargo.toml",
    "content": "[package]\nname = \"terse_cli_lib\"\nversion = \"0.1.2\"\nauthors = [\"David McNamee <david@mcnamee.io>\", \"Jeffray Zhang <zhangjeffray@gmail.com>\"]\nedition = \"2021\"\ndescription = \"A library for building no-boilerplate CLI apps\"\nlicense = \"MIT\"\n\n[dependencies]\nconvert_case = \"0.6.0\"\nproc-macro2 = \"1.0.92\"\nquote = \"1.0.37\"\nsyn = { version = \"2.0.89\", features = [\"full\"] }\n\n[lib]\npath = \"lib.rs\"\n"
  },
  {
    "path": "terse_cli_lib/lib.rs",
    "content": "use convert_case::{Case, Casing};\nuse proc_macro2::TokenStream;\nuse quote::{quote, ToTokens};\nuse syn::parse::{Parse, ParseStream};\nuse syn::{bracketed, parse2, Attribute, Ident, ItemFn, Token};\n\nfn extract_docs(a: &[Attribute]) -> impl Iterator<Item = &Attribute> {\n    a.iter().filter(|a| a.path().is_ident(\"doc\"))\n}\n\n#[derive(Debug)]\npub enum CommandMacroError {\n    InvalidCliArgumentError(InvalidCliArgumentError),\n    InvalidSubcommandFunctionError(InvalidSubcommandFunctionError),\n}\n\n/// don't use this directly, use apps/macros instead\npub fn command(\n    _attr: TokenStream,\n    item: TokenStream,\n) -> Result<TokenStream, CommandMacroError> {\n    let input = parse2::<ItemFn>(item).map_err(|err| CommandMacroError::InvalidSubcommandFunctionError(InvalidSubcommandFunctionError {\n        message: \"Failed to parse function\",\n            err,\n        }),\n    )?;\n    let fn_name = &input.sig.ident;\n    let args = &input.sig.inputs;\n    let (fields, arg_names): (Vec<_>, Vec<_>) = args\n        .iter()\n        .map(|arg| match arg {\n            syn::FnArg::Typed(syn::PatType { pat, ty, .. }) => {\n                Ok((quote! { #[arg(long)] #pat: #ty }, quote! { #pat }))\n            }\n            _ => Err(CommandMacroError::InvalidCliArgumentError(InvalidCliArgumentError(format!(\n                \"Invalid cli argument: {}\",\n                arg.to_token_stream()\n            )))),\n        })\n        .collect::<Result<Vec<_>, _>>()?\n        .into_iter()\n        .unzip();\n\n    let docs = extract_docs(&input.attrs);\n\n    let expanded = quote! {\n        mod #fn_name {\n            use super::#fn_name;\n            use clap::{command, Parser};\n\n            #[derive(Parser)]\n            #[command(version, about, long_about = None)]\n            #(#docs)*\n            pub struct Args {\n                #(#fields),*\n            }\n            pub fn run(Args { #(#arg_names),* }: Args) {\n                let result = #fn_name(#(#arg_names),*);\n                println!(\"{}\", result);\n            }\n        }\n        #input\n    };\n    Ok(expanded)\n}\n\n#[derive(Debug)]\npub enum SubcommandsMacroError {\n    InvalidIdentifierError(InvalidIdentifierError),\n    InvalidIdentifierListError(InvalidIdentifierListError),\n}\n\n/// don't use this directly, use apps/macros instead\npub fn subcommands(\n    item: TokenStream,\n) -> Result<TokenStream, SubcommandsMacroError> {\n    let MergeSubcommandsInput {\n        sub_doc,\n        cli_ident,\n        subcommands,\n    } = parse2::<MergeSubcommandsInput>(item).map_err(|err| SubcommandsMacroError::InvalidIdentifierListError(InvalidIdentifierListError {\n        message: \"subcommands only accepts lists of identifiers\",\n        err,\n    }))?;\n\n    let match_arms = subcommands.iter().map(|sc| {\n        let ident = &sc.ident;\n        let cmd_name = get_command_name(ident);\n        quote! {\n            Subcommands::#cmd_name(args) => #ident::run(args)\n        }\n    });\n    let command_enum_fields = subcommands.iter().map(|sc| {\n        let docs = extract_docs(&sc.attrs);\n        let ident = &sc.ident;\n        let cmd_name = get_command_name(ident);\n        quote! { \n            #(#docs)*\n            #cmd_name(#ident::Args)\n        }\n    });\n    let idents_tokens = subcommands.iter().map(|sc| sc.ident.to_token_stream());\n    let sub_doc_mod = sub_doc.clone();\n\n    let expanded = quote! {\n        #(#sub_doc_mod)*\n        mod #cli_ident {\n            use super::{#(#idents_tokens),*};\n            use clap::{command, Parser, Subcommand};\n\n            #[derive(Subcommand)]\n            #(#sub_doc)*\n            pub enum Subcommands {\n                #(#command_enum_fields),*\n            }\n            #[derive(Parser)]\n            #[command(version, about, long_about = None)]\n            pub struct Args {\n                #[command(subcommand)]\n                command: Subcommands,\n            }\n            pub fn run(Args { command }: Args) {\n                match command {\n                    #(#match_arms),*\n                };\n            }\n        }\n    };\n\n    Ok(expanded)\n}\n\n#[allow(dead_code)]\nstruct Subcommand {\n    attrs: Vec<Attribute>,\n    ident: Ident,\n}\n\nstruct MergeSubcommandsInput {\n    sub_doc: Vec<Attribute>,\n    cli_ident: Ident,\n    subcommands: Vec<Subcommand>,\n}\n\nimpl Parse for Subcommand {\n    fn parse(input: ParseStream) -> syn::Result<Self> {\n        let attrs = input.call(Attribute::parse_outer)?;\n        let ident: Ident = input.parse()?;\n        Ok(Subcommand { attrs, ident })\n    }\n}\n\nimpl Parse for MergeSubcommandsInput {\n    fn parse(input: ParseStream) -> syn::Result<Self> {\n        // parse any doc comments attached to the cli_ident\n        let sub_doc = Attribute::parse_outer(input)\n            .unwrap_or_default()\n            .into_iter()\n            .filter(|attr| attr.path().is_ident(\"doc\"))\n            .collect::<Vec<_>>();\n\n        let cli_ident: Ident = input.parse()?;\n        input.parse::<Token![,]>()?;\n        let content;\n        bracketed!(content in input);\n        let subcommands: syn::punctuated::Punctuated<Subcommand, Token![,]> =\n            content.parse_terminated(Subcommand::parse, Token![,])?;\n        Ok(MergeSubcommandsInput {\n            sub_doc,\n            cli_ident,\n            subcommands: subcommands.into_iter().collect(),\n        })\n    }\n}\n\nfn get_command_name(func_name: &Ident) -> Ident {\n    Ident::new(\n        &func_name.to_string().to_case(Case::UpperCamel),\n        func_name.span(),\n    )\n}\n\n#[allow(dead_code)]\n#[derive(Debug, Clone, Copy)]\npub struct InvalidIdentifierError(&'static str);\n\n#[allow(dead_code)]\n#[derive(Debug, Clone)]\npub struct InvalidIdentifierListError {\n    message: &'static str,\n    err: syn::Error,\n}\n\n#[allow(dead_code)]\n#[derive(Debug, Clone)]\npub struct InvalidSubcommandFunctionError {\n    message: &'static str,\n    err: syn::Error,\n}\n\n#[allow(dead_code)]\n#[derive(Debug, Clone)]\npub struct InvalidCliArgumentError(String);\n"
  },
  {
    "path": "tests/main.rs",
    "content": "mod test_cli_subcommand;\n"
  },
  {
    "path": "tests/test_cli_subcommand.rs",
    "content": "use terse_cli_lib::{command, subcommands};\nuse pretty_assertions::assert_eq;\nuse quote::quote;\n\n#[test]\npub fn test_command_macro() {\n    let in_stream = quote! {\n        fn my_func(a: i32, b: i32) -> i32 {\n            a + b\n        }\n    };\n    let attr_stream = quote! { #[subcommand] };\n    let out_stream = command(attr_stream, in_stream).unwrap();\n    let expected = quote! {\n        mod my_func {\n            use super::my_func;\n            use clap::{command, Parser};\n            #[derive(Parser)]\n            # [command (version , about , long_about = None)]\n            pub struct Args {\n                #[arg(long)]\n                a: i32,\n                #[arg(long)]\n                b: i32\n            }\n            pub fn run(Args { a, b }: Args) {\n                let result = my_func(a, b);\n                println!(\"{}\", result);\n            }\n        }\n        fn my_func(a: i32, b: i32) -> i32 {\n            a + b\n        }\n    };\n    assert_eq!(out_stream.to_string(), expected.to_string());\n}\n\n\n\n#[test]\npub fn test_command_macro_no_args() {\n    let in_stream = quote! {\n        /// doc comment test\n        fn my_func() -> i32 {\n            42\n        }\n    };\n    let attr_stream = quote! { #[subcommand] };\n    let out_stream = command(attr_stream, in_stream).unwrap();\n    let expected = quote! {\n        mod my_func {\n            use super::my_func;\n            use clap::{command, Parser};\n        \n            #[derive(Parser)]\n            #[command(version, about, long_about = None)]\n            #[doc = r\" doc comment test\"]\n            pub struct Args {}\n        \n            pub fn run(Args {}: Args) {\n                let result = my_func();\n                println!(\"{}\", result);\n            }\n        }\n\n        #[doc = r\" doc comment test\"]\n        fn my_func() -> i32 {\n            42\n        }        \n    };\n    assert_eq!(out_stream.to_string(), expected.to_string());\n}\n\n#[test]\npub fn test_subcommands_macro() {\n    let in_stream = quote! {\n        cli, [command_one, command_two]\n    };\n    let out_stream = subcommands(in_stream).unwrap();\n\n    let expected = quote! {\n        mod cli {\n            use super::{command_one, command_two};\n            use clap::{command, Parser, Subcommand};\n\n            #[derive(Subcommand)]\n            pub enum Subcommands {\n                CommandOne(command_one::Args),\n                CommandTwo(command_two::Args)\n            }\n\n            #[derive(Parser)]\n            #[command(version, about, long_about = None)]\n            pub struct Args {\n                #[command(subcommand)]\n                command: Subcommands,\n            }\n\n            pub fn run(Args { command }: Args) {\n                match command {\n                    Subcommands::CommandOne(args) => command_one::run(args),\n                    Subcommands::CommandTwo(args) => command_two::run(args)\n                };\n            }\n        }\n    };\n    assert_eq!(out_stream.to_string(), expected.to_string());\n}\n"
  }
]