diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 3ec30f7..4f55d25 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -22,3 +22,6 @@ common-path = "1" termcolor = "1" once_cell = "1" toml = "0.8" +tempfile = "3.3.0" +git2 = "0.18" +sha2 = "0.10.6" diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 249d46d..0acfc56 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,5 +1,4 @@ -//! This crate contains the proc macros used by [docify](https://crates.io/crates/docify). - +mod utils; use common_path::common_path; use derive_syn_parse::Parse; use once_cell::sync::Lazy; @@ -7,6 +6,7 @@ use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{quote, ToTokens}; use regex::Regex; + use std::{ cmp::min, collections::HashMap, @@ -16,16 +16,18 @@ use std::{ path::{Path, PathBuf}, str::FromStr, }; +use syn::parse::Parse; +use syn::parse::ParseStream; use syn::{ parse2, spanned::Spanned, token::Paren, visit::{self, Visit}, - AttrStyle, Attribute, Error, File, Ident, ImplItem, Item, LitStr, Meta, Result, Token, - TraitItem, + AttrStyle, Attribute, Error, Ident, ImplItem, Item, LitStr, Meta, Result, Token, TraitItem, }; use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; use toml::{Table, Value}; +use utils::*; use walkdir::WalkDir; fn line_start_position>(source: S, pos: usize) -> usize { @@ -465,7 +467,7 @@ fn export_internal( /// /// Which will expand to the `my_example` item in `path/to/file.rs` being embedded in a rust /// doc example marked with `ignore`. If you want to have your example actually run in rust -/// docs as well, you should use [`docify::embed_run!(..)`](`macro@embed_run`). +/// docs as well, you should use [`docify::embed_run!(..)`](`macro@embed_run`) instead. /// /// ### Arguments /// - `source_path`: the file path (relative to the current crate root) that contains the item @@ -498,7 +500,6 @@ fn export_internal( /// ```ignore /// /// Here is a cool example module: /// #[doc = docify::embed!("examples/my_example.rs")] -/// struct DocumentedItem /// ``` /// /// You are also free to embed multiple examples in the same set of doc comments: @@ -516,7 +517,7 @@ fn export_internal( /// and so they do not need to be run as well in the context where they are being embedded. If /// for whatever reason you _do_ want to also run an embedded example as a doc example, you can /// use [`docify::embed_run!(..)`](`macro@embed_run`) which removes the `ignore` tag from the -/// generated example but otherwise functions exactly like `#[docify::embed!(..)]` in every +/// generated example but otherwise functions exactly like `#[docify::embed!(..)` in every /// way. /// /// Output should match `rustfmt` output exactly. @@ -545,22 +546,331 @@ pub fn embed_run(tokens: TokenStream) -> TokenStream { } /// Used to parse args for `docify::embed!(..)` -#[derive(Parse)] +/// Used to parse args for `docify::embed!(..)` +/// Used to parse args for `docify::embed!(..)` + +mod kw { + syn::custom_keyword!(git); + syn::custom_keyword!(path); + syn::custom_keyword!(branch); + syn::custom_keyword!(commit); + syn::custom_keyword!(tag); + syn::custom_keyword!(item); +} + struct EmbedArgs { + git_url: Option, file_path: LitStr, - #[prefix(Option as comma)] - #[parse_if(comma.is_some())] + branch_name: Option, + commit_hash: Option, + tag_name: Option, + item_ident: Option, +} + +// implementing Debug for EmbedArgs for easier debugging +impl std::fmt::Debug for EmbedArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmbedArgs") + .field("git_url", &self.git_url.as_ref().map(|s| s.value())) + .field("file_path", &self.file_path.value()) + .field("branch_name", &self.branch_name.as_ref().map(|s| s.value())) + .field("commit_hash", &self.commit_hash.as_ref().map(|s| s.value())) + .field("tag_name", &self.tag_name.as_ref().map(|s| s.value())) + .field( + "item_ident", + &self.item_ident.as_ref().map(|i| i.to_string()), + ) + .finish() + } +} + +impl EmbedArgs { + /// Creates a new EmbedArgs instance with validation + fn new( + git_url: Option, + file_path: LitStr, + branch_name: Option, + commit_hash: Option, + tag_name: Option, + item_ident: Option, + ) -> Result { + let args = Self { + git_url, + file_path, + branch_name, + commit_hash, + tag_name, + item_ident, + }; + args.validate()?; + Ok(args) + } + + /// Validates all argument constraints + fn validate(&self) -> Result<()> { + self.validate_file_path()?; + self.validate_git_refs()?; + self.validate_git_dependencies()?; + self.validate_git_url()?; + Ok(()) + } + + /// Ensures file path is valid based on context + fn validate_file_path(&self) -> Result<()> { + let path = self.file_path.value(); + + // Check for URLs in file path when git_url is not provided + if self.git_url.is_none() && (path.starts_with("http://") || path.starts_with("https://")) { + return Err(Error::new( + self.file_path.span(), + "File path cannot be a URL. Use git: \"url\" for git repositories", + )); + } + + // Check for paths starting with ".." or "/" + if path.starts_with("..") || path.starts_with("/") { + let error_msg = if self.git_url.is_some() { + "When using git_url, please provide the correct file path in your git source. The path should not start with '..' or '/'." + } else { + "You can only embed files which are present in the current crate. For any other files, please provide the git_url to embed." + }; + return Err(Error::new(self.file_path.span(), error_msg)); + } + + Ok(()) + } + + /// Ensures git URL is valid if provided + fn validate_git_url(&self) -> Result<()> { + if let Some(git_url) = &self.git_url { + let url = git_url.value(); + if !url.starts_with("https://") { + return Err(Error::new( + git_url.span(), + "Please provide a valid Git URL starting with 'https://'", + )); + } + } + Ok(()) + } + + /// Ensures only one git reference type is specified + fn validate_git_refs(&self) -> Result<()> { + let ref_count = [&self.branch_name, &self.commit_hash, &self.tag_name] + .iter() + .filter(|&&x| x.is_some()) + .count(); + + if ref_count > 1 { + return Err(Error::new( + Span::call_site(), + "Only one of branch, commit, or tag can be specified", + )); + } + Ok(()) + } + + /// Ensures git-specific arguments are only used with git URLs + fn validate_git_dependencies(&self) -> Result<()> { + if self.git_url.is_none() + && (self.branch_name.is_some() || self.commit_hash.is_some() || self.tag_name.is_some()) + { + return Err(Error::new( + Span::call_site(), + "branch, commit, or tag can only be used with git parameter", + )); + } + Ok(()) + } + /// Parses positional arguments format + fn parse_positional(input: ParseStream) -> Result { + let file_path: LitStr = input.parse()?; + + // Check for empty file path + if file_path.value().trim().is_empty() { + return Err(Error::new(file_path.span(), "File path cannot be empty")); + } + + let item_ident = if input.peek(Token![,]) { + input.parse::()?; + Some(input.parse()?) + } else { + None + }; + + Self::new(None, file_path, None, None, None, item_ident) + } + + /// Parses named arguments format + fn parse_named(input: ParseStream) -> Result { + let mut builder = NamedArgsBuilder::new(); + + while !input.is_empty() { + builder.set_arg(input)?; + } + + builder.build() + } +} + +/// Builder for collecting named arguments during parsing +#[derive(Default)] +struct NamedArgsBuilder { + git_url: Option, + file_path: Option, + branch_name: Option, + commit_hash: Option, + tag_name: Option, item_ident: Option, } +impl NamedArgsBuilder { + fn new() -> Self { + Self::default() + } + + fn set_arg(&mut self, input: ParseStream) -> Result<()> { + let lookahead = input.lookahead1(); + + match () { + _ if lookahead.peek(kw::git) => self.parse_git_url(input), + _ if lookahead.peek(kw::path) => self.parse_file_path(input), + _ if lookahead.peek(kw::branch) => self.parse_branch(input), + _ if lookahead.peek(kw::commit) => self.parse_commit(input), + _ if lookahead.peek(kw::tag) => self.parse_tag(input), + _ if lookahead.peek(kw::item) => self.parse_item(input), + _ => return Err(lookahead.error()), + }?; + + // Handle trailing comma if more input exists + if !input.is_empty() { + input.parse::()?; + } + Ok(()) + } + + fn parse_git_url(&mut self, input: ParseStream) -> Result<()> { + let _: kw::git = input.parse()?; + let _: Token![:] = input.parse()?; + self.git_url = Some(input.parse()?); + Ok(()) + } + + fn parse_file_path(&mut self, input: ParseStream) -> Result<()> { + let _: kw::path = input.parse()?; + let _: Token![:] = input.parse()?; + self.file_path = Some(input.parse()?); + Ok(()) + } + + fn parse_branch(&mut self, input: ParseStream) -> Result<()> { + let _: kw::branch = input.parse()?; + let _: Token![:] = input.parse()?; + self.branch_name = Some(input.parse()?); + Ok(()) + } + + fn parse_commit(&mut self, input: ParseStream) -> Result<()> { + let _: kw::commit = input.parse()?; + let _: Token![:] = input.parse()?; + self.commit_hash = Some(input.parse()?); + Ok(()) + } + + fn parse_tag(&mut self, input: ParseStream) -> Result<()> { + let _: kw::tag = input.parse()?; + let _: Token![:] = input.parse()?; + self.tag_name = Some(input.parse()?); + Ok(()) + } + + fn parse_item(&mut self, input: ParseStream) -> Result<()> { + let _: kw::item = input.parse()?; + let _: Token![:] = input.parse()?; + self.item_ident = Some(input.parse()?); + Ok(()) + } + + fn build(self) -> Result { + let file_path = self + .file_path + .ok_or_else(|| Error::new(Span::call_site(), "path parameter is required"))?; + + EmbedArgs::new( + self.git_url, + file_path, + self.branch_name, + self.commit_hash, + self.tag_name, + self.item_ident, + ) + } +} + +impl Parse for EmbedArgs { + fn parse(input: ParseStream) -> Result { + if !input.peek(kw::git) && !input.peek(kw::path) { + return Self::parse_positional(input); + } + Self::parse_named(input) + } +} + impl ToTokens for EmbedArgs { fn to_tokens(&self, tokens: &mut TokenStream2) { - tokens.extend(self.file_path.to_token_stream()); - let Some(item_ident) = &self.item_ident else { + // For positional arguments style + if self.git_url.is_none() && !self.has_named_args() { + self.file_path.to_tokens(tokens); + if let Some(ref item) = self.item_ident { + Token![,](Span::call_site()).to_tokens(tokens); + item.to_tokens(tokens); + } return; - }; - tokens.extend(quote!(,)); - tokens.extend(item_ident.to_token_stream()); + } + + // For named arguments style + let mut args = TokenStream2::new(); + + if let Some(ref git) = self.git_url { + quote!(git: #git,).to_tokens(&mut args); + } + + quote!(path: #self.file_path,).to_tokens(&mut args); + + if let Some(ref branch) = self.branch_name { + quote!(branch: #branch,).to_tokens(&mut args); + } + + if let Some(ref commit) = self.commit_hash { + quote!(commit: #commit,).to_tokens(&mut args); + } + + if let Some(ref tag) = self.tag_name { + quote!(tag: #tag,).to_tokens(&mut args); + } + + if let Some(ref item) = self.item_ident { + quote!(item: #item,).to_tokens(&mut args); + } + + tokens.extend(args); + } +} + +// Add this helper method to EmbedArgs impl +impl EmbedArgs { + fn has_named_args(&self) -> bool { + self.branch_name.is_some() + || self.commit_hash.is_some() + || self.tag_name.is_some() + || self.git_url.is_some() + } + + // Add this method to convert to TokenStream2 + pub fn to_token_stream(&self) -> TokenStream2 { + let mut tokens = TokenStream2::new(); + self.to_tokens(&mut tokens); + tokens } } @@ -710,7 +1020,7 @@ impl<'ast> SupportedVisitItem<'ast> for ItemVisitor { } } -#[derive(Copy, Clone, PartialEq, Eq)] +#[derive(Copy, Clone, PartialEq, Eq, Debug)] enum ResultStyle { Export, ExportContent, @@ -963,62 +1273,137 @@ fn source_excerpt<'a, T: ToTokens>( .join("\n")) } -/// Inner version of [`embed_internal`] that just returns the result as a [`String`]. fn embed_internal_str( tokens: impl Into, langs: Vec, ) -> Result { - let args = parse2::(tokens.into())?; - // return blank result if we can't properly resolve `caller_crate_root` - let Some(root) = caller_crate_root() else { - return Ok(String::from("")); - }; - let file_path = root.join(args.file_path.value()); - let source_code = match fs::read_to_string(&file_path) { - Ok(src) => src, - Err(_) => { - return Err(Error::new( + let args: EmbedArgs = parse2::(tokens.into())?; + + // get the root of the crate + let crate_root = caller_crate_root() + .ok_or_else(|| Error::new(Span::call_site(), "Failed to resolve caller crate root"))?; + if let Some(git_url) = &args.git_url { + let allow_updates = + !std::env::var("DOCS_RS").is_ok() && !std::env::var("DOCIFY_DISABLE_UPDATES").is_ok(); + + // Determine git option type and value + + // git option type and value is determined in get_snippet_file_name + // git option type value is calcualted based on , branch / commit / tag and is hashed into 8 characters + + // if git option it present the name of the snippet will be of the format + // [git-url-hash]-[git-option-hash]-[path-hash]-[ident_name]-[full-commit-hash].rs + + // if no git option is present the name of the snippet will be of the format + // this will be applicable only when none of branch / commit / tag is provided + // [git-url-hash]-[path-hash]-[ident_name]-[full-commit-hash].rs + + // first create the snippet file name based on the passed arguments + let new_snippet = get_snippet_file_name(git_url.value().as_str(), &args, allow_updates)?; + // snippet file name is created now check if it already exists + + // create the snippets directory if it doesn't exist + let snippets_dir = get_or_create_snippets_dir()?; + + let existing_snippet_path = + check_existing_snippet(&new_snippet, allow_updates, &snippets_dir)?; + + // Use existing snippet if available, otherwise proceed with cloning + + if let Some(snippet_name) = existing_snippet_path { + let snippet_path = snippets_dir.join(snippet_name); + let content = fs::read_to_string(&snippet_path).map_err(|e| { + Error::new( + args.file_path.span(), + format!( + "Failed to read snippet file: {} at path: {}", + e, + snippet_path.display() + ), + ) + })?; + + let formatted_content = fix_indentation(&content); + let output = into_example(&formatted_content, &langs); + return Ok(output); + } + + // returns the directory of the cloned repo with caching + + let repo_dir = clone_and_checkout_repo( + git_url.value().as_str(), + &new_snippet.commit_hash.as_ref().unwrap(), + )?; + + let source_path = repo_dir.join(&args.file_path.value()); + + // extract the item from the file + let extracted_content: String = + extract_item_from_file(&source_path, &args.item_ident.as_ref().unwrap().to_string())?; + + let snippet_path = snippets_dir.join(&new_snippet.full_name); + + // write the extracted content to the snippet file + fs::write(&snippet_path, extracted_content.clone()).map_err(|e| { + Error::new( + Span::call_site(), + format!("Failed to write snippet file: {}", e), + ) + })?; + + let formatted_content = fix_indentation(&extracted_content); + let output: String = into_example(&formatted_content, &langs); + + Ok(output) + } else { + let file_path = crate_root.join(args.file_path.value()); + + let source_code = fs::read_to_string(&file_path).map_err(|e| { + Error::new( args.file_path.span(), format!( - "Could not read the specified path '{}'.", + "Could not read the specified path '{}': {}", file_path.display(), + e ), - )) - } - }; - let parsed = source_code.parse::()?; - let source_file = parse2::(parsed)?; + ) + })?; + + let source_file = syn::parse_file(&source_code)?; + + let output = if let Some(ident) = args.item_ident.as_ref() { + let mut visitor = ItemVisitor { + search: ident.clone(), + results: Vec::new(), + }; + visitor.visit_file(&source_file); + + if visitor.results.is_empty() { + return Err(Error::new( + ident.span(), + format!( + "Could not find docify export item '{}' in '{}'.", + ident, + file_path.display() + ), + )); + } - let output = if let Some(ident) = args.item_ident { - let mut visitor = ItemVisitor { - search: ident.clone(), - results: Vec::new(), + let mut results: Vec = Vec::new(); + for (item, style) in visitor.results { + let excerpt = source_excerpt(&source_code, &item, style)?; + let formatted = fix_indentation(excerpt); + let example = into_example(formatted.as_str(), &langs); + results.push(example); + } + results.join("\n") + } else { + into_example(source_code.as_str(), &langs) }; - visitor.visit_file(&source_file); - if visitor.results.is_empty() { - return Err(Error::new( - ident.span(), - format!( - "Could not find docify export item '{}' in '{}'.", - ident, - file_path.display(), - ), - )); - } - let mut results: Vec = Vec::new(); - for (item, style) in visitor.results { - let excerpt = source_excerpt(&source_code, &item, style)?; - let formatted = fix_indentation(excerpt); - let example = into_example(formatted.as_str(), &langs); - results.push(example); - } - results.join("\n") - } else { - into_example(source_code.as_str(), &langs) - }; - Ok(output) -} + Ok(output) + } +} /// Internal implementation behind [`macro@embed`]. fn embed_internal( tokens: impl Into, diff --git a/macros/src/tests.rs b/macros/src/tests.rs index 9b89c1f..10ab79f 100644 --- a/macros/src/tests.rs +++ b/macros/src/tests.rs @@ -193,3 +193,150 @@ fn bar() { "#; assert_eq!(fix_leading_indentation(input), output); } + +#[test] +fn test_embed_args_basic() { + // Test basic file path only + let args = parse2::(quote!("src/lib.rs")).unwrap(); + assert!(args.git_url.is_none()); + assert_eq!(args.file_path.value(), "src/lib.rs"); + assert!(args.item_ident.is_none()); + assert!(args.branch_name.is_none()); + assert!(args.commit_hash.is_none()); + assert!(args.tag_name.is_none()); + + // Test file path with item + let args = parse2::(quote!("src/lib.rs", my_function)).unwrap(); + assert!(args.git_url.is_none()); + assert_eq!(args.file_path.value(), "src/lib.rs"); + assert_eq!(args.item_ident.unwrap().to_string(), "my_function"); +} + +#[test] +fn test_embed_args_git() { + // Test with git URL + let args = parse2::(quote!(git: "https://github.com/user/repo", path: "src/lib.rs")) + .unwrap(); + assert_eq!( + args.git_url.unwrap().value(), + "https://github.com/user/repo" + ); + assert_eq!(args.file_path.value(), "src/lib.rs"); + + // Test with git URL and branch + let args = parse2::(quote!( + git: "https://github.com/user/repo", + path: "src/lib.rs", + branch: "main" + )) + .unwrap(); + assert_eq!(args.branch_name.unwrap().value(), "main"); +} + +#[test] +fn test_embed_args_git_refs() { + // Test with commit hash + let args = parse2::(quote!( + git: "https://github.com/user/repo", + path: "src/lib.rs", + commit: "abc123" + )) + .unwrap(); + assert_eq!(args.commit_hash.unwrap().value(), "abc123"); + + // Test with tag + let args = parse2::(quote!( + git: "https://github.com/user/repo", + path: "src/lib.rs", + tag: "v1.0.0" + )) + .unwrap(); + assert_eq!(args.tag_name.unwrap().value(), "v1.0.0"); +} + +#[test] +fn test_embed_args_with_item() { + // Test git URL with item + let args = parse2::(quote!( + git: "https://github.com/user/repo", + path: "src/lib.rs", + item: my_function + )) + .unwrap(); + assert_eq!(args.item_ident.unwrap().to_string(), "my_function"); +} +#[test] +fn test_embed_args_invalid() { + // Test empty path + assert!(parse2::(quote!("")).is_err()); + + // Test multiple git refs (should fail) + assert!( + parse2::(quote!( + git: "https://github.com/user/repo", + path: "src/lib.rs", + branch: "main", + tag: "v1.0.0" + )) + .is_err(), + "Should fail when multiple git refs are provided" + ); + + // Test git refs without git URL (should fail) + assert!( + parse2::(quote!( + path: "src/lib.rs", + branch: "main" + )) + .is_err(), + "Should fail when git refs are provided without git URL" + ); + + // Test missing path with git URL (should fail) + assert!( + parse2::(quote!( + git: "https://github.com/user/repo" + )) + .is_err(), + "Should fail when path is missing" + ); + + // Test invalid URL format + assert!( + parse2::(quote!( + git: "not a valid url", + path: "src/lib.rs" + )) + .is_err(), + "Should fail with invalid git URL format" + ); + + // Test empty git URL + assert!( + parse2::(quote!( + git: "", + path: "src/lib.rs" + )) + .is_err(), + "Should fail with empty git URL" + ); +} +#[test] +fn test_embed_args_complex() { + // Test full featured usage + let args = parse2::(quote!( + git: "https://github.com/user/repo", + path: "src/lib.rs", + branch: "feature/new", + item: test_function + )) + .unwrap(); + + assert_eq!( + args.git_url.unwrap().value(), + "https://github.com/user/repo" + ); + assert_eq!(args.file_path.value(), "src/lib.rs"); + assert_eq!(args.branch_name.unwrap().value(), "feature/new"); + assert_eq!(args.item_ident.unwrap().to_string(), "test_function"); +} diff --git a/macros/src/utils/mod.rs b/macros/src/utils/mod.rs new file mode 100644 index 0000000..a85235d --- /dev/null +++ b/macros/src/utils/mod.rs @@ -0,0 +1,497 @@ +use git2::{Direction, FetchOptions, RemoteCallbacks, Repository}; +use proc_macro2::Span; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::visit::Visit; +use syn::Error; +use syn::Result; + +use crate::{caller_crate_root, source_excerpt, ItemVisitor}; + +pub fn extract_item_from_file(file_path: &Path, item_ident: &str) -> Result { + let source_code = fs::read_to_string(file_path).map_err(|e| { + Error::new( + Span::call_site(), + format!( + "Could not read the specified path '{}': {}", + file_path.display(), + e + ), + ) + })?; + + let mut visitor = ItemVisitor { + search: syn::parse_str(item_ident)?, + results: Vec::new(), + }; + visitor.visit_file(&syn::parse_file(&source_code)?); + if visitor.results.is_empty() { + return Err(Error::new( + Span::call_site(), + format!( + "Could not find docify export item '{}' in '{}'.", + item_ident, + file_path.display() + ), + )); + } + + let (item, style) = visitor.results.first().unwrap(); + source_excerpt(&source_code, item, *style) +} + +/// Checks if there is an active internet connection by attempting to connect to multiple reliable hosts +// comment +/// Helper function to convert git2::Error to syn::Error +fn git_err_to_syn(err: git2::Error) -> syn::Error { + syn::Error::new(Span::call_site(), format!("Git error: {}", err)) +} + +/// Helper function to convert io::Error to syn::Error +fn io_err_to_syn(err: std::io::Error) -> syn::Error { + syn::Error::new(Span::call_site(), format!("IO error: {}", err)) +} + +pub fn get_remote_commit_sha_without_clone( + git_url: &str, + branch: Option<&str>, + tag: Option<&str>, +) -> Result { + let temp_dir = tempfile::Builder::new() + .prefix("docify-temp-") + .rand_bytes(5) + .tempdir() + .map_err(io_err_to_syn)?; + + let repo = Repository::init(temp_dir.path()).map_err(git_err_to_syn)?; + let mut remote = repo.remote_anonymous(git_url).map_err(git_err_to_syn)?; + + // Set up fetch options + let mut fetch_opts = FetchOptions::new(); + fetch_opts.depth(1); // Only fetch the most recent commit + + // First connect to get default branch if needed + remote.connect(Direction::Fetch).map_err(git_err_to_syn)?; + + // Determine which ref to fetch + let refspec = if let Some(tag_name) = tag { + format!("refs/tags/{}:refs/tags/{}", tag_name, tag_name) + } else { + let branch_ref = if let Some(b) = branch { + format!("refs/heads/{}", b) + } else { + // Get default branch name + let default_branch = remote + .default_branch() + .map_err(git_err_to_syn)? + .as_str() + .map(String::from) + .ok_or_else(|| Error::new(Span::call_site(), "Invalid default branch name"))?; + + // Convert refs/heads/main to just refs/heads/main + if !default_branch.starts_with("refs/heads/") { + format!("refs/heads/{}", default_branch) + } else { + default_branch + } + }; + format!("{}:{}", branch_ref, branch_ref) + }; + + // Disconnect before fetch to ensure clean state + remote.disconnect().map_err(git_err_to_syn)?; + + // Fetch the specific ref + remote + .fetch(&[&refspec], Some(&mut fetch_opts), None) + .map_err(git_err_to_syn)?; + + // Get commit ID + let reference = if let Some(tag_name) = tag { + repo.find_reference(&format!("refs/tags/{}", tag_name)) + .map_err(git_err_to_syn)? + } else { + let ref_name = if let Some(b) = branch { + format!("refs/heads/{}", b) + } else { + // Use the actual fetched ref name + let default_branch = remote + .default_branch() + .map_err(git_err_to_syn)? + .as_str() + .map(String::from) + .ok_or_else(|| Error::new(Span::call_site(), "Invalid default branch name"))?; + + if !default_branch.starts_with("refs/heads/") { + format!("refs/heads/{}", default_branch) + } else { + default_branch + } + }; + repo.find_reference(&ref_name).map_err(git_err_to_syn)? + }; + + let commit_id = reference.peel_to_commit().map_err(git_err_to_syn)?.id(); + + Ok(commit_id.to_string()) +} + +pub fn get_or_create_commit_dir(git_url: &str, commit_sha: &str) -> Result { + let temp_base = std::env::temp_dir().join("docify-repos"); + + // Extract repo name from git URL + let repo_name = git_url + .split('/') + .last() + .map(|s| { + if s.ends_with(".git") { + s.strip_suffix(".git").unwrap_or(s) + } else { + s + } + }) + .unwrap_or("repo"); + + // Use first 8 chars of commit hash + let short_commit = &commit_sha[..8]; + + // Create directory name: docify-{short_commit}-{repo_name} + let dir_name = format!("docify-{}-{}", short_commit, repo_name); + let commit_dir = temp_base.join(dir_name); + + if commit_dir.exists() { + Ok(commit_dir) + } else { + fs::create_dir_all(&commit_dir).map_err(|e| { + Error::new( + Span::call_site(), + format!("Failed to create commit directory: {}", e), + ) + })?; + Ok(commit_dir) + } +} + +/// Clones repo and checks out specific commit, reusing existing clone if available +pub fn clone_and_checkout_repo(git_url: &str, commit_sha: &str) -> Result { + let commit_dir = get_or_create_commit_dir(git_url, commit_sha)?; + + // Check if repo is already cloned and checked out + if commit_dir.join(".git").exists() { + return Ok(commit_dir); + } + + let mut callbacks = RemoteCallbacks::new(); + callbacks.transfer_progress(|_p| true); + + let mut fetch_opts = FetchOptions::new(); + fetch_opts.remote_callbacks(callbacks); + fetch_opts.depth(1); + + let repo = Repository::init(&commit_dir).map_err(git_err_to_syn)?; + let mut remote = repo.remote_anonymous(git_url).map_err(git_err_to_syn)?; + + remote + .fetch( + &[&format!("+{commit_sha}:refs/heads/temp")], + Some(&mut fetch_opts), + None, + ) + .map_err(git_err_to_syn)?; + + let commit_id = git2::Oid::from_str(commit_sha).map_err(git_err_to_syn)?; + let commit = repo.find_commit(commit_id).map_err(git_err_to_syn)?; + let tree = commit.tree().map_err(git_err_to_syn)?; + + repo.checkout_tree(tree.as_object(), None) + .map_err(git_err_to_syn)?; + repo.set_head_detached(commit_id).map_err(git_err_to_syn)?; + + Ok(commit_dir) +} + +/// Represents a parsed snippet filename +pub struct SnippetFile { + pub prefix: String, + pub commit_hash: Option, + pub full_name: String, +} + +/// Functions to handle snippet file operations +impl SnippetFile { + pub fn new_without_commit( + git_url: &str, + git_option_type: &str, + git_option_value: &str, + path: &str, + item_ident: &str, + ) -> Self { + let prefix = format!( + "{}-{}-{}-{}", + hash_git_url(git_url), + hash_git_option(git_option_type, git_option_value), + hash_string(path), + item_ident, + ); + Self { + prefix: prefix.clone(), + commit_hash: None, + full_name: format!("{}.rs", prefix), + } + } + + pub fn new_with_commit( + git_url: &str, + git_option_type: &str, + git_option_value: &str, + path: &str, + commit_sha: &str, + item_ident: &str, + ) -> Self { + let prefix = format!( + "{}-{}-{}-{}", + hash_git_url(git_url), + hash_git_option(git_option_type, git_option_value), + hash_string(path), + item_ident, + ); + + let full_name = format!("{}-{}.rs", prefix, commit_sha); + Self { + prefix, + commit_hash: Some(commit_sha.to_string()), + full_name, + } + } + + pub fn find_existing(prefix: &str) -> Option { + // Get the crate root path + let crate_root = match crate::caller_crate_root() { + Some(root) => root, + None => { + return None; + } + }; + + // Use absolute path by joining with crate root + let snippets_dir = crate_root.join(".snippets"); + + // Check if directory exists and is actually a directory + if !snippets_dir.exists() { + return None; + } + + fs::read_dir(snippets_dir).ok()?.find_map(|entry| { + let entry = entry.ok()?; + let file_name = entry.file_name().to_string_lossy().to_string(); + + if file_name.starts_with(prefix) { + // Extract commit hash from filename if it exists + let commit_hash = file_name + .strip_suffix(".rs")? + .rsplit('-') + .next() + .map(|s| s.to_string()); + + Some(Self { + prefix: prefix.to_string(), + commit_hash, + full_name: file_name, + }) + } else { + None + } + }) + } + + /// Creates a new SnippetFile for default branch case (when no branch is specified) + pub fn new_for_default_branch( + git_url: &str, + path: &str, + item_ident: &str, + commit_sha: Option<&str>, + ) -> Self { + let prefix = format!( + "{}-{}-{}", + hash_git_url(git_url), + hash_string(path), + item_ident, + ); + + if let Some(commit) = commit_sha { + let full_name = format!("{}-{}.rs", prefix, commit); + Self { + prefix, + commit_hash: Some(commit.to_string()), + full_name, + } + } else { + Self { + prefix: prefix.clone(), + commit_hash: None, + full_name: format!("{}.rs", prefix), + } + } + } +} + +fn hash_string(input: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + format!("{:.8x}", hasher.finalize()) +} + +fn hash_git_option(option_type: &str, value: &str) -> String { + hash_string(&format!("{}-{}", option_type, value)) +} + +/// Normalizes a git URL by removing trailing .git if present +fn normalize_git_url(url: &str) -> &str { + url.strip_suffix(".git").unwrap_or(url) +} + +fn hash_git_url(url: &str) -> String { + let normalized_url = normalize_git_url(url); + hash_string(normalized_url) +} + +/// Creates and returns the snippets directory path, ensuring it exists +pub fn get_or_create_snippets_dir() -> Result { + let crate_root = caller_crate_root() + .ok_or_else(|| Error::new(Span::call_site(), "Failed to resolve caller crate root"))?; + + let snippets_dir = crate_root.join(".snippets"); + fs::create_dir_all(&snippets_dir).map_err(|e| { + Error::new( + Span::call_site(), + format!("Failed to create .snippets directory: {}", e), + ) + })?; + + Ok(snippets_dir) +} + +/// Creates a new snippet file based on git options and internet connectivity +pub fn get_snippet_file_name( + git_url: &str, + args: &crate::EmbedArgs, + allow_updates: bool, +) -> Result { + if let Some(hash) = &args.commit_hash { + // If commit hash is provided, use it regardless of internet connectivity + return Ok(SnippetFile::new_with_commit( + git_url, + "commit", + &hash.value(), + &args.file_path.value(), + &hash.value(), + &args.item_ident.as_ref().unwrap().to_string(), + )); + } + + if let Some(tag) = &args.tag_name { + return if allow_updates { + let commit_sha = + get_remote_commit_sha_without_clone(git_url, None, Some(tag.value().as_str()))?; + Ok(SnippetFile::new_with_commit( + git_url, + "tag", + &tag.value(), + &args.file_path.value(), + &commit_sha, + &args.item_ident.as_ref().unwrap().to_string(), + )) + } else { + Ok(SnippetFile::new_without_commit( + git_url, + "tag", + &tag.value(), + &args.file_path.value(), + &args.item_ident.as_ref().unwrap().to_string(), + )) + }; + } + + if let Some(branch) = &args.branch_name { + return if allow_updates { + let commit_sha = + get_remote_commit_sha_without_clone(git_url, Some(branch.value().as_str()), None)?; + Ok(SnippetFile::new_with_commit( + git_url, + "branch", + branch.value().as_str(), + &args.file_path.value(), + &commit_sha, + &args.item_ident.as_ref().unwrap().to_string(), + )) + } else { + Ok(SnippetFile::new_without_commit( + git_url, + "branch", + branch.value().as_str(), + &args.file_path.value(), + &args.item_ident.as_ref().unwrap().to_string(), + )) + }; + } + + // Default branch case - more flexible naming + if allow_updates { + let commit_sha = get_remote_commit_sha_without_clone(git_url, None, None)?; + Ok(SnippetFile::new_for_default_branch( + git_url, + &args.file_path.value(), + &args.item_ident.as_ref().unwrap().to_string(), + Some(&commit_sha), + )) + } else { + Ok(SnippetFile::new_for_default_branch( + git_url, + &args.file_path.value(), + &args.item_ident.as_ref().unwrap().to_string(), + None, + )) + } +} + +/// Checks if a snippet file already exists and handles updating if necessary +/// Returns Some(filename) if a valid snippet exists, None if we need to create a new one +pub fn check_existing_snippet( + new_snippet: &SnippetFile, + allow_updates: bool, + snippets_dir: &Path, +) -> Result> { + let Some(existing_snippet) = SnippetFile::find_existing(&new_snippet.prefix) else { + if !allow_updates { + return Err(Error::new( + Span::call_site(), + "No matching snippet found and no internet connection available", + )); + } + return Ok(None); + }; + + if !allow_updates { + return Ok(Some(existing_snippet.full_name)); + } + + // Online mode comparison + if let (Some(existing_hash), Some(new_hash)) = + (&existing_snippet.commit_hash, &new_snippet.commit_hash) + { + if existing_hash == new_hash { + return Ok(Some(existing_snippet.full_name)); + } + + // Remove old snippet file + fs::remove_file(snippets_dir.join(&existing_snippet.full_name)).map_err(|e| { + Error::new( + Span::call_site(), + format!("Failed to remove old snippet file: {}", e), + ) + })?; + } + + Ok(None) +}