aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src
diff options
context:
space:
mode:
authorHsiangNianian <i@jyunko.cn>2025-05-25 02:03:56 +0800
committerHsiangNianian <i@jyunko.cn>2025-05-25 10:47:38 +0800
commit72ae30380b7a62e8066270524ce056e14da08112 (patch)
tree9814ff4178350f64d55a049a36f859600350f033 /src
parentd812eb597d35721c2156a2093336fcf448a6e3e5 (diff)
downloadsoon-72ae30380b7a62e8066270524ce056e14da08112.tar.gz
soon-72ae30380b7a62e8066270524ce056e14da08112.zip
feat: Add initial project files and CI configuration
- Created CI workflow for continuous integration using GitHub Actions. - Added Python version specification. - Initialized Cargo.toml and Cargo.lock for Rust project dependencies. - Implemented main functionality in Rust with command-line interface using Clap. - Added Python project configuration with Maturin for building and publishing. - Implemented command history prediction feature in Python.
Diffstat (limited to 'src')
-rw-r--r--src/main.rs213
1 files changed, 213 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..a16d18a
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,213 @@
+use clap::{Parser, Subcommand};
+use counter::Counter;
+use std::collections::HashMap;
+use std::env;
+use std::fs::File;
+use std::io::{BufRead, BufReader};
+use std::path::PathBuf;
+use colored::*;
+
+#[derive(Parser, Debug)]
+#[command(name = "soon", about = "Predict your next shell command based on history")]
+struct Cli {
+ #[command(subcommand)]
+ command: Option<Commands>,
+ #[arg(long)]
+ shell: Option<String>,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+ /// Show the most likely next command
+ Now,
+ /// Show most used commands
+ Stats,
+ /// Train prediction (WIP)
+ Learn,
+ /// Display detected current shell
+ Which,
+}
+
+fn detect_shell() -> String {
+ if let Ok(shell) = env::var("SHELL") {
+ let shell = shell.to_lowercase();
+ if shell.contains("zsh") {
+ "zsh".to_string()
+ } else if shell.contains("bash") {
+ "bash".to_string()
+ } else if shell.contains("fish") {
+ "fish".to_string()
+ } else {
+ "unknown".to_string()
+ }
+ } else {
+ "unknown".to_string()
+ }
+}
+
+fn history_path(shell: &str) -> Option<PathBuf> {
+ let home = dirs::home_dir()?;
+ match shell {
+ "bash" => Some(home.join(".bash_history")),
+ "zsh" => Some(home.join(".zsh_history")),
+ "fish" => Some(home.join(".local/share/fish/fish_history")),
+ _ => None,
+ }
+}
+
+#[derive(Debug)]
+struct HistoryItem {
+ cmd: String,
+ path: Option<String>,
+}
+
+fn load_history(shell: &str) -> Vec<HistoryItem> {
+ let path = match history_path(shell) {
+ Some(p) => p,
+ None => return vec![],
+ };
+ let file = match File::open(&path) {
+ Ok(f) => f,
+ Err(_) => return vec![],
+ };
+ let reader = BufReader::new(file);
+
+ let mut result = Vec::new();
+ if shell == "fish" {
+ let mut last_cmd: Option<String> = None;
+ let mut last_path: Option<String> = None;
+ for line in reader.lines().flatten() {
+ if let Some(cmd) = line.strip_prefix("- cmd: ") {
+ last_cmd = Some(cmd.trim().to_string());
+ last_path = None;
+ } else if let Some(path) = line.strip_prefix(" path: ") {
+ last_path = Some(path.trim().to_string());
+ }
+
+ if let Some(cmd) = &last_cmd {
+ if line.starts_with("- cmd: ") || line.is_empty() {
+ result.push(HistoryItem {
+ cmd: cmd.clone(),
+ path: last_path.clone(),
+ });
+ last_cmd = None;
+ last_path = None;
+ }
+ }
+ }
+
+ if let Some(cmd) = last_cmd {
+ result.push(HistoryItem {
+ cmd,
+ path: last_path,
+ });
+ }
+ } else {
+ for line in reader.lines().flatten() {
+ let line = if shell == "zsh" {
+ line.trim_start_matches(|c: char| c == ':' || c.is_digit(10) || c == ';').trim().to_string()
+ } else {
+ line.trim().to_string()
+ };
+ if !line.is_empty() {
+ result.push(HistoryItem { cmd: line, path: None });
+ }
+ }
+ }
+ result
+}
+
+fn weighted_suggestions(history: &[HistoryItem], cwd: &str, shell: &str) -> Option<String> {
+ let dir_name = std::path::Path::new(cwd)
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or("");
+ let mut scores: HashMap<&str, f64> = HashMap::new();
+ for (i, item) in history.iter().rev().enumerate() {
+ let mut score = 100.0 - i as f64 * 0.5;
+
+ if let Some(ref p) = item.path {
+ if p == cwd {
+ score *= 2.0;
+ }
+ }
+
+ if !cwd.is_empty() && item.cmd.contains(cwd) {
+ score *= 1.5;
+ }
+
+ if !dir_name.is_empty() && item.cmd.contains(dir_name) {
+ score *= 1.2;
+ }
+ if !shell.is_empty() && item.cmd.contains(shell) {
+ score *= 1.1;
+ }
+ *scores.entry(item.cmd.as_str()).or_insert(0.0) += score;
+ }
+ scores.into_iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|(cmd, _)| cmd.to_string())
+}
+
+fn soon_now(shell: &str) {
+ let history = load_history(shell);
+ if history.is_empty() {
+ eprintln!("{}", format!("⚠️ Failed to load history for {shell}.").red());
+ std::process::exit(1);
+ }
+ let cwd = env::current_dir().unwrap_or_default();
+ let cwd = cwd.to_string_lossy();
+ let suggestion = weighted_suggestions(&history, &cwd, shell);
+ println!("\n{}", "🔮 You might run next:".magenta().bold());
+ if let Some(cmd) = suggestion {
+ println!("{} {}", "👉".green().bold(), cmd.green().bold());
+ } else {
+ println!("{}", "No suggestion found.".yellow());
+ }
+}
+
+fn soon_stats(shell: &str) {
+ let history = load_history(shell);
+ if history.is_empty() {
+ eprintln!("{}", format!("⚠️ Failed to load history for {shell}.").red());
+ std::process::exit(1);
+ }
+ let mut counter = Counter::<&String, i32>::new();
+ for item in &history {
+ counter.update([&item.cmd]);
+ }
+ let mut most_common: Vec<_> = counter.most_common().into_iter().collect();
+ most_common.truncate(10);
+
+ println!("{}", "📊 Top 10 most used commands".bold().cyan());
+ println!("{:<30}{}", "Command".cyan().bold(), "Usage Count".magenta().bold());
+ for (cmd, freq) in most_common {
+ println!("{:<30}{}", cmd, freq);
+ }
+}
+
+fn soon_learn(_shell: &str) {
+ println!("{}", "🧠 [soon learn] feature under development...".yellow());
+}
+
+fn soon_which(shell: &str) {
+ println!("{}", format!("🕵️ Current shell: {shell}").yellow().bold());
+}
+
+fn main() {
+ let cli = Cli::parse();
+ let shell = cli.shell.clone().unwrap_or_else(detect_shell);
+
+ if shell == "unknown" && !matches!(cli.command, Some(Commands::Which)) {
+ eprintln!("{}", "⚠️ Unknown shell. Please specify with --shell.".red());
+ std::process::exit(1);
+ }
+
+ match cli.command {
+ Some(Commands::Now) => soon_now(&shell),
+ Some(Commands::Stats) => soon_stats(&shell),
+ Some(Commands::Learn) => soon_learn(&shell),
+ Some(Commands::Which) => soon_which(&shell),
+ None => {
+ soon_now(&shell);
+ }
+ }
+} \ No newline at end of file