gitmoji_rs/cmd/
mod.rs

1use std::process::exit;
2
3use console::Term;
4use serde::{Deserialize, Serialize};
5use tracing::{info, warn};
6use url::Url;
7
8use crate::git::has_staged_changes;
9use crate::{
10    git, recovery, EmojiFormat, Error, GitmojiConfig, Result, EXIT_CANNOT_UPDATE, EXIT_NO_CONFIG,
11};
12
13mod commit;
14pub use self::commit::*;
15
16mod config;
17pub use self::config::*;
18
19#[cfg(feature = "hook")]
20mod hook;
21
22mod list;
23use self::list::print_gitmojis;
24
25mod search;
26use self::search::filter;
27
28mod update;
29use self::update::update_gitmojis;
30
31async fn get_config_or_stop() -> GitmojiConfig {
32    match read_config_or_fail().await {
33        Ok(config) => config,
34        Err(err) => {
35            warn!("Oops, cannot read config because {err}");
36            eprintln!(
37                "⚠️  No configuration found, try run `gitmoji init` to fetch a configuration"
38            );
39            exit(EXIT_NO_CONFIG)
40        }
41    }
42}
43
44async fn update_config_or_stop(config: GitmojiConfig) -> GitmojiConfig {
45    let url = config.update_url().to_string();
46    match update_gitmojis(config).await {
47        Ok(config) => config,
48        Err(err) => {
49            warn!("Oops, cannot update the config because {err}");
50            eprintln!("⚠️  Configuration not updated, maybe check the update url '{url}'");
51            exit(EXIT_CANNOT_UPDATE)
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct CommitTitleDescription {
58    pub title: String,
59    pub description: Option<String>,
60}
61
62#[tracing::instrument(skip(term))]
63async fn ask_commit_title_description(
64    config: &GitmojiConfig,
65    term: &Term,
66) -> Result<CommitTitleDescription> {
67    let CommitParams {
68        gitmoji,
69        scope,
70        title,
71        description,
72    } = get_commit_params(config, term)?;
73
74    let gitmoji = match config.format() {
75        EmojiFormat::UseCode => gitmoji.code(),
76        EmojiFormat::UseEmoji => gitmoji.emoji(),
77    };
78
79    let title = scope.map_or_else(
80        || format!("{gitmoji} {title}"),
81        |scope| format!("{gitmoji} {scope}{title}"),
82    );
83
84    let result = CommitTitleDescription { title, description };
85    Ok(result)
86}
87
88/// Commit using Gitmoji
89#[tracing::instrument(skip(term))]
90pub async fn commit(all: bool, amend: bool, extra_args: &[String], term: &Term) -> Result<()> {
91    let config = get_config_or_stop().await;
92
93    if !amend && !has_staged_changes().await? {
94        eprintln!("No change to commit");
95        return Ok(());
96    }
97
98    let commit = if let Ok(Some(recovered)) = recovery::read() {
99        if recovery::ask(term, &recovered).unwrap_or(false) {
100            recovered
101        } else {
102            ask_commit_title_description(&config, term).await?
103        }
104    } else {
105        ask_commit_title_description(&config, term).await?
106    };
107
108    // Add before commit
109    let all = all || config.auto_add();
110
111    // Commit
112    let status = git::commit(
113        all,
114        amend,
115        config.signed(),
116        &commit.title,
117        commit.description.as_deref(),
118        extra_args,
119    )
120    .await?;
121
122    if status.success() {
123        if let Err(err) = recovery::clear() {
124            warn!("{err}");
125        }
126        Ok(())
127    } else {
128        if let Err(err) = recovery::write(&commit) {
129            warn!("{err}");
130        }
131        Err(Error::FailToCommit)
132    }
133}
134
135/// Configure Gitmoji
136///
137/// # Errors
138/// Returns an error if configuration creation fails or dialog interaction fails
139#[tracing::instrument(skip(term))]
140pub async fn config(default: bool, term: &Term) -> Result<()> {
141    let config = if default {
142        GitmojiConfig::default()
143    } else {
144        create_config(term)?
145    };
146    info!("Loading gitmojis from {}", config.update_url());
147    update_config_or_stop(config).await;
148
149    Ok(())
150}
151
152/// Search a gitmoji
153#[tracing::instrument]
154pub async fn search(text: &str) -> Result<()> {
155    let config = get_config_or_stop().await;
156    let result = filter(config.gitmojis(), text);
157    print_gitmojis(&result);
158    Ok(())
159}
160
161/// List all Gitmojis
162#[tracing::instrument]
163pub async fn list() -> Result<()> {
164    let config = get_config_or_stop().await;
165    print_gitmojis(config.gitmojis());
166    Ok(())
167}
168
169/// Update the configuration with the URL
170#[tracing::instrument]
171pub async fn update_config(url: Option<Url>) -> Result<()> {
172    let mut config = read_config_or_default().await;
173    if let Some(url) = url {
174        config.set_update_url(url);
175    }
176    let result = update_config_or_stop(config).await;
177    print_gitmojis(result.gitmojis());
178
179    Ok(())
180}
181
182/// Create hook
183#[cfg(feature = "hook")]
184#[tracing::instrument]
185pub async fn create_hook() -> Result<()> {
186    hook::create().await
187}
188
189/// Remove hook
190#[tracing::instrument]
191#[cfg(feature = "hook")]
192pub async fn remove_hook() -> Result<()> {
193    hook::remove().await
194}
195
196/// Open /dev/tty directly for interactive prompts in hook context
197#[cfg(all(feature = "hook", unix))]
198fn open_tty_term() -> std::io::Result<Term> {
199    use std::fs::OpenOptions;
200
201    let tty_read = OpenOptions::new().read(true).open("/dev/tty")?;
202    let tty_write = OpenOptions::new().write(true).open("/dev/tty")?;
203
204    Ok(Term::read_write_pair(tty_read, tty_write))
205}
206
207/// Apply hook
208#[cfg(feature = "hook")]
209#[tracing::instrument(skip(_term))]
210pub async fn apply_hook(
211    dest: std::path::PathBuf,
212    source: Option<String>,
213    _term: &Term,
214) -> Result<()> {
215    use tokio::io::AsyncWriteExt;
216
217    // Open /dev/tty directly for the hook context
218    // This is needed because git hooks don't have a proper terminal attached
219    let term = open_tty_term().map_err(|e| Error::Hook {
220        cause: format!("Cannot open /dev/tty: {e}"),
221    })?;
222
223    let config = get_config_or_stop().await;
224
225    let CommitTitleDescription { title, description } =
226        ask_commit_title_description(&config, &term).await?;
227
228    info!("Write commit message to {dest:?} with source: {source:?}");
229    let contents = tokio::fs::read(&dest).await.unwrap_or_default();
230    let mut file = tokio::fs::File::create(&dest).await?;
231
232    file.write_all(title.as_bytes()).await?;
233    file.write_all(b"\n\n").await?;
234
235    if let Some(description) = description {
236        file.write_all(description.as_bytes()).await?;
237        file.write_all(b"\n").await?;
238    }
239    file.write_all(&contents).await?;
240
241    Ok(())
242}