diff --git a/.miyabi/README.md b/.miyabi/README.md index 9e26f91..2c4d215 100644 --- a/.miyabi/README.md +++ b/.miyabi/README.md @@ -1,57 +1,316 @@ # .miyabi Directory -Miyabi CLI設定ディレクトリ +Miyabi CLI設定・拡張ディレクトリ -## 構造 +## ディレクトリ構造 ``` .miyabi/ +├── config.toml # メイン設定ファイル +├── README.md # このファイル ├── agents/ │ └── specs/ # Agent仕様定義 -├── commands/ # カスタムコマンド +├── commands/ # カスタムスラッシュコマンド ├── prompts/ # 再利用可能プロンプト -├── templates/ # テンプレート -├── sessions/ # 保存されたセッション -└── config.toml # 設定ファイル +├── templates/ # コード・ドキュメントテンプレート +├── sessions/ # 保存されたセッションデータ +└── tasks/ # タスク管理データ ``` -## 設定ファイル +## 設定ファイル (config.toml) -`config.toml` で設定をカスタマイズ: +### API設定 ```toml [api] -model = "claude-sonnet-4-20250514" +# API Key (環境変数 ANTHROPIC_API_KEY でも設定可能) +api_key = "sk-ant-..." + +# 使用モデル +model = "claude-sonnet-4-5-20250929" + +# 最大トークン数 max_tokens = 8192 +# タイムアウト (秒) +timeout_secs = 120 + +# リトライ回数 +max_retries = 3 + +# 拡張思考モード +thinking = false + +# システムプロンプト +system_prompt = "You are a helpful assistant" +``` + +### UI設定 + +```toml [ui] +# テーマ: tokyo-night, dracula, monokai, etc. theme = "tokyo-night" + +# Vimキーバインド vim_mode = false +# サイドバー表示 +show_sidebar = false + +# ステータスバー表示 +show_status_bar = true + +# パンくずリスト表示 +show_breadcrumb = true + +# コードの行番号表示 +show_line_numbers = true +``` + +### セッション設定 + +```toml [session] +# 自動保存 auto_save = true +# 保存間隔 (秒) +auto_save_interval = 30 + +# 最大セッション数 +max_sessions = 100 +``` + +### ツール設定 + +```toml [tools] +# Bashツール有効化 enable_bash = true + +# ファイル操作ツール有効化 +enable_file_tools = true + +# 検索ツール有効化 +enable_search_tools = true + +# 低リスク操作の自動承認 +auto_approve_low_risk = false + +# Bashタイムアウト (秒) +bash_timeout = 120 ``` ## カスタムコマンド -`commands/` 配下に `*.md` ファイルを作成してカスタムコマンドを定義。 +`commands/` 配下に `.md` ファイルを作成してスラッシュコマンドを定義。 + +### 例: /review コマンド + +`commands/review.md`: +```markdown +# コードレビュー + +以下のコードをレビューしてください: + +1. バグの可能性 +2. パフォーマンス問題 +3. セキュリティ脆弱性 +4. コーディング規約違反 + +改善提案も含めてください。 +``` + +使用: TUIで `/review` と入力 + +## プロンプトテンプレート + +`prompts/` 配下に再利用可能なプロンプトを保存。 + +### 例: Rustコードレビュー + +`prompts/rust-review.md`: +```markdown +# Rustコードレビューチェックリスト + +- [ ] unwrap/expect の使用箇所 +- [ ] エラーハンドリング +- [ ] 所有権とライフタイム +- [ ] unsafe コードの妥当性 +- [ ] Clippy警告の確認 +``` + +## Agent仕様 + +`agents/specs/` にカスタムAgent仕様を定義。 + +### 例: コードリファクタリングAgent + +`agents/specs/refactor-agent.yaml`: +```yaml +name: refactor-agent +version: "1.0" +description: コードリファクタリング専門Agent + +capabilities: + - code_analysis + - refactoring + - testing + +preferences: + style: functional + error_handling: result + max_iterations: 10 +``` ## セッション管理 +### コマンドライン操作 + ```bash # セッション一覧 miyabi sessions +# 特定セッションで再開 +miyabi --session + # Markdownエクスポート miyabi sessions -m -# 削除 +# JSONエクスポート +miyabi sessions -e + +# セッション削除 miyabi sessions -d ``` +### セッションファイル形式 + +セッションは `sessions/` に JSON 形式で保存: + +```json +{ + "id": "uuid", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T01:00:00Z", + "model": "claude-sonnet-4-5-20250929", + "messages": [...] +} +``` + +## プロジェクトルール (.miyabirules) + +プロジェクトルートに `.miyabirules` ファイルを配置して、プロジェクト固有のルールを定義: + +```yaml +version: 1 + +rules: + - name: "no-unwrap" + pattern: ".unwrap()" + suggestion: "Use ? operator or proper error handling" + file_extensions: ["rs"] + severity: "warning" + enabled: true + + - name: "no-println-debug" + pattern: "println!" + suggestion: "Use tracing macros for logging" + file_extensions: ["rs"] + severity: "info" + +agent_preferences: + codegen: + style: "functional" + error_handling: "result" + min_score: 80 + clippy_strict: true + +settings: + auto_format: true + max_file_size: 1048576 +``` + +## 環境変数 + +設定ファイルの値は環境変数でオーバーライド可能: + +```bash +# 必須 +export ANTHROPIC_API_KEY="sk-ant-..." + +# オプション +export MIYABI_MODEL="claude-sonnet-4-5-20250929" +export MIYABI_MAX_TOKENS="16384" +export MIYABI_THINKING="true" +export MIYABI_THEME="dracula" +``` + +## Core Library機能の利用 + +### キャッシュ + +LLMレスポンスのキャッシュで高速化: + +```rust +use miyabi_core::{create_llm_cache, LLMCacheKey}; + +let cache = create_llm_cache(); // 1時間TTL +``` + +### フィーチャーフラグ + +機能の段階的ロールアウト: + +```rust +use miyabi_core::FeatureFlagManager; + +let flags = FeatureFlagManager::new(); +flags.set_flag("new_ui", true); +``` + +### Circuit Breaker + +API障害時の保護: + +```rust +use miyabi_core::CircuitBreaker; + +let breaker = CircuitBreaker::default(); +// 5回失敗で開く、60秒後に半開 +``` + +### プラグイン + +カスタム機能の追加: + +```rust +use miyabi_core::{Plugin, PluginManager}; + +let manager = PluginManager::new(); +manager.register(Box::new(MyPlugin))?; +``` + +## トラブルシューティング + +### API接続エラー + +1. API Keyの確認: `echo $ANTHROPIC_API_KEY` +2. ネットワーク接続確認 +3. タイムアウト値の増加 + +### セッション破損 + +1. `sessions/` から該当ファイルを削除 +2. `miyabi sessions` で確認 + +### 設定が反映されない + +1. 設定ファイルの構文確認: `cat config.toml` +2. 環境変数の確認 +3. CLIオプションの優先順位確認 + ## 使い方 ```bash @@ -61,6 +320,9 @@ miyabi # バージョン情報 miyabi version +# ステータス確認 +miyabi status + # ヘルプ miyabi --help ``` diff --git a/.miyabi/commands/explain.md b/.miyabi/commands/explain.md new file mode 100644 index 0000000..baf31ac --- /dev/null +++ b/.miyabi/commands/explain.md @@ -0,0 +1,38 @@ +# Code Explanation + +Provide a detailed explanation of the specified code: + +## Analysis Sections + +### 1. Overview +- Purpose and responsibility +- Where it fits in the architecture +- Key dependencies + +### 2. Data Flow +- Inputs and outputs +- Data transformations +- Side effects + +### 3. Key Concepts +- Design patterns used +- Algorithms implemented +- Important abstractions + +### 4. Line-by-Line Breakdown +For complex sections, explain: +- What each block does +- Why it's implemented this way +- Any non-obvious behavior + +### 5. Usage Examples +```rust +// Example of how to use this code +``` + +### 6. Potential Improvements +- Suggestions for enhancement +- Known limitations +- Future considerations + +Target audience: Developers new to this codebase. diff --git a/.miyabi/commands/refactor.md b/.miyabi/commands/refactor.md new file mode 100644 index 0000000..e6b5a82 --- /dev/null +++ b/.miyabi/commands/refactor.md @@ -0,0 +1,20 @@ +# Code Refactoring + +Refactor the specified code with the following goals: + +1. **Improve Readability** - Clear naming, logical structure, remove complexity +2. **Reduce Duplication** - Extract common patterns into reusable functions +3. **Enhance Maintainability** - Better separation of concerns, SOLID principles +4. **Optimize Performance** - Only if it doesn't sacrifice readability + +Guidelines: +- Preserve existing behavior (no functional changes) +- Keep existing tests passing +- Add comments only where logic isn't self-evident +- Prefer small, focused functions over large ones + +Output format: +1. Analysis of current issues +2. Refactoring plan +3. Refactored code with explanations +4. Summary of improvements diff --git a/.miyabi/commands/review.md b/.miyabi/commands/review.md new file mode 100644 index 0000000..bc5fd12 --- /dev/null +++ b/.miyabi/commands/review.md @@ -0,0 +1,17 @@ +# Code Review + +Please review the provided code for: + +1. **Bugs & Logic Errors** - Identify potential bugs, edge cases, and logical issues +2. **Performance** - Spot inefficient algorithms, unnecessary allocations, or N+1 queries +3. **Security** - Check for vulnerabilities like injection, XSS, or unsafe operations +4. **Code Style** - Ensure consistency with project conventions +5. **Error Handling** - Verify proper error propagation and user-friendly messages + +For each issue found, provide: +- Location (file:line) +- Severity (Critical/High/Medium/Low) +- Description +- Suggested fix with code example + +End with a summary score (1-10) and overall assessment. diff --git a/.miyabi/commands/test.md b/.miyabi/commands/test.md new file mode 100644 index 0000000..c7a446c --- /dev/null +++ b/.miyabi/commands/test.md @@ -0,0 +1,33 @@ +# Test Generation + +Generate comprehensive tests for the specified code: + +## Test Types +1. **Unit Tests** - Test individual functions in isolation +2. **Integration Tests** - Test component interactions +3. **Edge Cases** - Boundary conditions, empty inputs, max values +4. **Error Cases** - Invalid inputs, failure scenarios + +## Requirements +- Use the project's testing framework +- Follow Arrange-Act-Assert pattern +- Descriptive test names that explain the scenario +- Mock external dependencies +- Aim for high coverage of critical paths + +## Output Format +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_function_name_scenario() { + // Arrange + // Act + // Assert + } +} +``` + +Include explanation of test strategy and coverage goals. diff --git a/.miyabi/prompts/rust-best-practices.md b/.miyabi/prompts/rust-best-practices.md new file mode 100644 index 0000000..edccc3f --- /dev/null +++ b/.miyabi/prompts/rust-best-practices.md @@ -0,0 +1,41 @@ +# Rust Best Practices Checklist + +When writing or reviewing Rust code, ensure: + +## Error Handling +- [ ] Use `Result` for fallible operations +- [ ] Avoid `.unwrap()` and `.expect()` in library code +- [ ] Use `?` operator for error propagation +- [ ] Create custom error types with `thiserror` +- [ ] Provide context with `.context()` or `.with_context()` + +## Memory & Performance +- [ ] Prefer `&str` over `String` for function parameters +- [ ] Use `Cow` when ownership is conditional +- [ ] Avoid unnecessary cloning +- [ ] Use iterators instead of index loops +- [ ] Consider `Vec::with_capacity()` for known sizes + +## Safety +- [ ] Minimize `unsafe` code +- [ ] Document safety invariants for `unsafe` blocks +- [ ] Validate all external input +- [ ] Use `#[must_use]` for important return values + +## Async +- [ ] Use `tokio::spawn` for concurrent tasks +- [ ] Avoid blocking operations in async context +- [ ] Use `tokio::select!` for racing futures +- [ ] Handle cancellation properly + +## Testing +- [ ] Unit tests in same file with `#[cfg(test)]` +- [ ] Integration tests in `tests/` directory +- [ ] Use `#[should_panic]` for expected panics +- [ ] Test error cases, not just happy paths + +## Documentation +- [ ] Doc comments for public items (`///`) +- [ ] Examples in doc comments +- [ ] Module-level documentation (`//!`) +- [ ] Link related items with `[`item`]` diff --git a/.miyabirules b/.miyabirules new file mode 100644 index 0000000..2c34acb --- /dev/null +++ b/.miyabirules @@ -0,0 +1,76 @@ +# Miyabi CLI Project Rules +# This file defines project-specific rules and agent preferences + +version: 1 + +rules: + # Rust best practices + - name: "no-unwrap" + pattern: ".unwrap()" + suggestion: "Use ? operator or proper error handling instead of unwrap()" + file_extensions: ["rs"] + severity: "warning" + enabled: true + + - name: "no-expect" + pattern: ".expect(" + suggestion: "Consider using ? operator or match for better error messages" + file_extensions: ["rs"] + severity: "info" + enabled: true + + - name: "no-println-debug" + pattern: "println!" + suggestion: "Use tracing macros (info!, debug!, error!) for logging" + file_extensions: ["rs"] + severity: "info" + enabled: true + + - name: "no-dbg" + pattern: "dbg!" + suggestion: "Remove debug macro before committing" + file_extensions: ["rs"] + severity: "warning" + enabled: true + + - name: "no-todo" + pattern: "TODO" + suggestion: "Address TODO comments or create GitHub issues" + file_extensions: ["rs", "ts", "js", "md"] + severity: "info" + enabled: true + + - name: "no-fixme" + pattern: "FIXME" + suggestion: "Address FIXME comments before merging" + file_extensions: ["rs", "ts", "js"] + severity: "warning" + enabled: true + +# Agent-specific preferences +agent_preferences: + codegen: + style: "functional" + error_handling: "result" + min_score: 80 + clippy_strict: true + + review: + focus_areas: + - "error_handling" + - "performance" + - "security" + min_score: 70 + + refactor: + preserve_tests: true + max_changes_per_file: 100 + +# Global settings +settings: + auto_format: true + max_file_size: 1048576 + ignore_patterns: + - "target/" + - "node_modules/" + - ".git/" diff --git a/Cargo.lock b/Cargo.lock index 2853c35..1d976c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -745,6 +747,21 @@ dependencies = [ "wasip2", ] +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "glob" version = "0.3.3" @@ -1139,6 +1156,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.82" @@ -1161,6 +1188,20 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libgit2-sys" +version = "0.17.0+1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libredox" version = "0.1.10" @@ -1171,6 +1212,32 @@ dependencies = [ "libc", ] +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1293,16 +1360,19 @@ dependencies = [ "chrono", "dirs", "futures", + "git2", "glob", "regex", "reqwest", "serde", "serde_json", + "serde_yaml", "tempfile", "thiserror 2.0.17", "tokio", "toml", "tracing", + "tracing-subscriber", "uuid", ] @@ -2007,6 +2077,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2522,6 +2605,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" @@ -2532,12 +2625,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -2593,6 +2689,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/MIYABI.md b/MIYABI.md index f38ce3a..6fa0a5d 100644 Binary files a/MIYABI.md and b/MIYABI.md differ diff --git a/README.md b/README.md index dcec9ee..2233dea 100644 --- a/README.md +++ b/README.md @@ -166,23 +166,218 @@ Agent mode features: - Iteration limits for safety - JSON output format option +## Core Library Features + +The `miyabi-core` crate provides powerful utilities for building AI applications. + +### Git Utilities + +```rust +use miyabi_core::{find_git_root, get_current_branch, is_in_git_repo}; + +// Find repository root +let root = find_git_root()?; + +// Get current branch +let branch = get_current_branch(&root)?; + +// Check if in git repo +if is_in_git_repo(&path) { + println!("In a git repository"); +} +``` + +### Logger System + +```rust +use miyabi_core::{init_logger, LogLevel, LogFormat}; + +// Initialize with defaults +init_logger(); + +// Or with custom config +use miyabi_core::LoggerConfig; +let config = LoggerConfig { + level: LogLevel::Debug, + format: LogFormat::Json, + ..Default::default() +}; +init_logger_with_config(config); +``` + +### Retry with Backoff + +```rust +use miyabi_core::{retry_with_backoff, BackoffRetryConfig}; + +let config = BackoffRetryConfig::default(); +let result = retry_with_backoff(config, || async { + // Your operation that might fail + make_api_call().await +}).await?; +``` + +### Circuit Breaker + +```rust +use miyabi_core::{CircuitBreaker, CircuitState}; + +let breaker = CircuitBreaker::default(); + +let result = breaker.call(|| { + Box::pin(async { + // Your operation + Ok::<_, std::io::Error>(()) + }) +}).await; + +// Check state +match breaker.state().await { + CircuitState::Closed => println!("Normal operation"), + CircuitState::Open => println!("Circuit open - blocking calls"), + CircuitState::HalfOpen => println!("Testing recovery"), +} +``` + +### Rules System (.miyabirules) + +Support for project-specific rules via `.miyabirules` files: + +```yaml +# .miyabirules +version: 1 +rules: + - name: "no-unwrap" + pattern: ".unwrap()" + suggestion: "Use ? operator or proper error handling" + file_extensions: ["rs"] + severity: "warning" + +agent_preferences: + codegen: + style: "functional" + error_handling: "result" +``` + +```rust +use miyabi_core::{RulesLoader, MiyabiRules}; + +let loader = RulesLoader::new(project_root); +let rules = loader.load_or_default()?; + +// Get rules for a file +let applicable = rules.rules_for_file(Path::new("src/main.rs")); +``` + +### Cache System + +```rust +use miyabi_core::{create_llm_cache, create_api_cache, LLMCacheKey}; + +// LLM response cache (1 hour TTL) +let cache = create_llm_cache(); +let key = LLMCacheKey::new("prompt", "model", Some(0.7)); + +cache.insert(key.clone(), "response".to_string()).await; +let cached = cache.get(&key).await; + +// API cache (30 min TTL) +let api_cache = create_api_cache(); +``` + +### Plugin System + +```rust +use miyabi_core::{Plugin, PluginManager, PluginMetadata, PluginContext, PluginResult}; +use anyhow::Result; + +struct MyPlugin; + +impl Plugin for MyPlugin { + fn metadata(&self) -> PluginMetadata { + PluginMetadata { + name: "my-plugin".to_string(), + version: "1.0.0".to_string(), + description: Some("My custom plugin".to_string()), + author: None, + } + } + + fn init(&mut self) -> Result<()> { + Ok(()) + } + + fn execute(&self, ctx: &PluginContext) -> Result { + Ok(PluginResult { + success: true, + message: Some("Done".to_string()), + data: None, + }) + } +} + +// Register and execute +let manager = PluginManager::new(); +manager.register(Box::new(MyPlugin))?; +let result = manager.execute("my-plugin", &PluginContext::default())?; +``` + +### Feature Flags + +```rust +use miyabi_core::{FeatureFlagManager, FeatureFlag}; + +let flags = FeatureFlagManager::new(); + +// Simple flag +flags.set_flag("new_feature", true); + +if flags.is_enabled("new_feature") { + // Use new feature +} + +// With rollout percentage +flags.set_flag_with_options( + "beta_feature", + true, + Some("Beta testing".to_string()), + Some(0.5), // 50% rollout +); + +// Load from config +use std::collections::HashMap; +let mut config = HashMap::new(); +config.insert("flag1".to_string(), true); +flags.load_from_map(config); +``` + ## Project Structure ``` miyabi-cli-standalone/ ├── crates/ -│ ├── miyabi-cli/ # CLI entry point -│ ├── miyabi-core/ # Core library -│ │ ├── agent/ # Agent system -│ │ ├── config.rs # Configuration -│ │ ├── session.rs # Session management +│ ├── miyabi-cli/ # CLI entry point +│ ├── miyabi-core/ # Core library +│ │ ├── agent.rs # Agent system +│ │ ├── anthropic.rs # Anthropic API client +│ │ ├── cache.rs # TTL cache system +│ │ ├── config.rs # Configuration +│ │ ├── error_policy.rs # Circuit breaker +│ │ ├── feature_flags.rs # Feature flag management +│ │ ├── git.rs # Git utilities +│ │ ├── logger.rs # Logging system +│ │ ├── plugin.rs # Plugin system +│ │ ├── retry.rs # Retry with backoff +│ │ ├── rules.rs # .miyabirules support +│ │ ├── session.rs # Session management +│ │ ├── tools.rs # Built-in tools │ │ └── ... -│ └── miyabi-tui/ # TUI implementation -│ ├── app.rs # Main application -│ ├── views.rs # UI views +│ └── miyabi-tui/ # TUI implementation +│ ├── app.rs # Main application +│ ├── views.rs # UI views │ └── ... -├── .miyabi/ # Local config -├── Cargo.toml # Workspace config +├── .miyabi/ # Local config +├── Cargo.toml # Workspace config └── README.md ``` diff --git a/crates/miyabi-cli/src/main.rs b/crates/miyabi-cli/src/main.rs index 5b21ae9..92517bb 100644 --- a/crates/miyabi-cli/src/main.rs +++ b/crates/miyabi-cli/src/main.rs @@ -1,9 +1,26 @@ //! Miyabi CLI - Main entry point use clap::{Parser, Subcommand}; +use miyabi_core::{FeatureFlagManager, RulesLoader}; use std::path::PathBuf; use tracing_subscriber::EnvFilter; +/// Global feature flags manager +static FEATURE_FLAGS: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Get the global feature flags manager +pub fn feature_flags() -> &'static FeatureFlagManager { + FEATURE_FLAGS.get_or_init(|| { + let manager = FeatureFlagManager::new(); + // Default feature flags + manager.set_flag("extended_thinking", true); + manager.set_flag("auto_save_sessions", true); + manager.set_flag("syntax_highlighting", true); + manager.set_flag("vim_mode", false); + manager + }) +} + #[derive(Parser)] #[command(name = "miyabi")] #[command(author, version, about = "Miyabi - Autonomous AI Development Framework", long_about = None)] @@ -54,6 +71,12 @@ enum Commands { }, /// Show version and system information Version, + /// Show project rules (.miyabirules) + Rules { + /// Show detailed rule information + #[arg(short, long)] + verbose: bool, + }, /// Run agent with a prompt (autonomous execution) Agent { /// The prompt to execute @@ -145,11 +168,40 @@ async fn main() -> anyhow::Result<()> { } } Some(Commands::Status) => { + use miyabi_core::config::Config; + + let config = Config::load().unwrap_or_default(); + println!("Miyabi Status: Ready"); - println!( - "Config path: {:?}", - miyabi_core::config::Config::default_path() - ); + println!(); + println!("Config: {}", Config::default_path().display()); + println!("Sessions: {}", config.sessions_dir().display()); + println!("Model: {}", config.api.model); + println!(); + + // Load and show rules info + let cwd = std::env::current_dir().unwrap_or_default(); + let loader = RulesLoader::new(cwd); + match loader.load() { + Ok(Some(rules)) => { + println!("Rules: {} rules loaded", rules.rules.len()); + if !rules.agent_preferences.is_empty() { + println!("Agents: {} agent preferences", rules.agent_preferences.len()); + } + } + Ok(None) => { + println!("Rules: No .miyabirules found"); + } + Err(e) => { + println!("Rules: Error loading - {}", e); + } + } + + // Show feature flags + let flags = feature_flags(); + let all_flags = flags.get_all_flags(); + let enabled_count = all_flags.iter().filter(|f| f.enabled).count(); + println!("Flags: {}/{} enabled", enabled_count, all_flags.len()); } Some(Commands::Init) => { use miyabi_core::config::Config; @@ -284,6 +336,78 @@ async fn main() -> anyhow::Result<()> { std::env::consts::ARCH ); } + Some(Commands::Rules { verbose }) => { + let cwd = std::env::current_dir().unwrap_or_default(); + let loader = RulesLoader::new(cwd.clone()); + + match loader.load() { + Ok(Some(rules)) => { + println!("Project Rules (.miyabirules)"); + println!("============================"); + println!(); + + if rules.rules.is_empty() { + println!("No rules defined."); + } else { + println!("Rules ({}):", rules.rules.len()); + for rule in &rules.rules { + let status = if rule.enabled { "✓" } else { "✗" }; + let severity = match rule.severity.as_str() { + "error" => "🔴", + "warning" => "🟡", + _ => "🔵", + }; + println!(" {} {} {} - {}", status, severity, rule.name, rule.suggestion); + + if verbose { + if let Some(pattern) = &rule.pattern { + println!(" Pattern: {}", pattern); + } + if !rule.file_extensions.is_empty() { + println!(" Extensions: {}", rule.file_extensions.join(", ")); + } + println!(); + } + } + } + + if !rules.agent_preferences.is_empty() { + println!(); + println!("Agent Preferences ({}):", rules.agent_preferences.len()); + for (agent, prefs) in &rules.agent_preferences { + println!(" {}:", agent); + if let Some(style) = &prefs.style { + println!(" Style: {}", style); + } + if let Some(handling) = &prefs.error_handling { + println!(" Error Handling: {}", handling); + } + if let Some(score) = prefs.min_score { + println!(" Min Score: {}", score); + } + } + } + + if verbose && !rules.settings.is_empty() { + println!(); + println!("Settings:"); + for (key, value) in &rules.settings { + println!(" {}: {}", key, value); + } + } + } + Ok(None) => { + println!("No .miyabirules file found in {} or parent directories.", cwd.display()); + println!(); + println!("Create a .miyabirules file to define project-specific rules."); + println!("See: miyabi --help for more information."); + } + Err(e) => { + eprintln!("Error loading rules: {}", e); + std::process::exit(1); + } + } + } Some(Commands::Agent { prompt, max_iterations, diff --git a/crates/miyabi-core/Cargo.toml b/crates/miyabi-core/Cargo.toml index b12c4e4..e3103bc 100644 --- a/crates/miyabi-core/Cargo.toml +++ b/crates/miyabi-core/Cargo.toml @@ -16,6 +16,7 @@ serde_json = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } chrono = { workspace = true } uuid = { workspace = true } reqwest = { workspace = true } @@ -23,6 +24,8 @@ futures = { workspace = true } async-trait = { workspace = true } glob = { workspace = true } regex = { workspace = true } +git2 = "0.19" +serde_yaml = "0.9" dirs = "5" toml = "0.8" diff --git a/crates/miyabi-core/src/cache.rs b/crates/miyabi-core/src/cache.rs new file mode 100644 index 0000000..fea85a7 --- /dev/null +++ b/crates/miyabi-core/src/cache.rs @@ -0,0 +1,240 @@ +//! Caching utilities for Miyabi +//! +//! Provides in-memory caching with TTL support for LLM responses and other expensive operations + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// Cache entry with TTL support +#[derive(Debug, Clone)] +pub struct CacheEntry { + /// Cached value + pub value: T, + /// Expiration timestamp + pub expires_at: Instant, +} + +impl CacheEntry { + /// Create a new cache entry with the given TTL + pub fn new(value: T, ttl: Duration) -> Self { + Self { + value, + expires_at: Instant::now() + ttl, + } + } + + /// Check if the cache entry has expired + pub fn is_expired(&self) -> bool { + Instant::now() > self.expires_at + } +} + +/// Thread-safe TTL cache +#[derive(Debug)] +pub struct TTLCache { + inner: Arc>>>, + default_ttl: Duration, +} + +impl TTLCache +where + K: Hash + Eq + Clone + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ + /// Create a new TTL cache with default TTL + pub fn new(default_ttl: Duration) -> Self { + Self { + inner: Arc::new(RwLock::new(HashMap::new())), + default_ttl, + } + } + + /// Get a value from the cache + pub async fn get(&self, key: &K) -> Option { + let mut cache = self.inner.write().await; + + if let Some(entry) = cache.get(key) { + if entry.is_expired() { + cache.remove(key); + None + } else { + Some(entry.value.clone()) + } + } else { + None + } + } + + /// Insert a value into the cache with default TTL + pub async fn insert(&self, key: K, value: V) { + self.insert_with_ttl(key, value, self.default_ttl).await; + } + + /// Insert a value into the cache with custom TTL + pub async fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) { + let mut cache = self.inner.write().await; + cache.insert(key, CacheEntry::new(value, ttl)); + } + + /// Remove a value from the cache + pub async fn remove(&self, key: &K) -> Option { + let mut cache = self.inner.write().await; + cache.remove(key).map(|entry| entry.value) + } + + /// Clear all expired entries + pub async fn cleanup_expired(&self) -> usize { + let mut cache = self.inner.write().await; + let initial_size = cache.len(); + cache.retain(|_, entry| !entry.is_expired()); + initial_size - cache.len() + } + + /// Get cache statistics + pub async fn stats(&self) -> CacheStats { + let cache = self.inner.read().await; + let total_entries = cache.len(); + let expired_entries = cache.values().filter(|entry| entry.is_expired()).count(); + + CacheStats { + total_entries, + expired_entries, + active_entries: total_entries - expired_entries, + } + } + + /// Clear all entries + pub async fn clear(&self) { + let mut cache = self.inner.write().await; + cache.clear(); + } +} + +/// Cache statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheStats { + /// Total entries + pub total_entries: usize, + /// Expired entries + pub expired_entries: usize, + /// Active entries + pub active_entries: usize, +} + +/// LLM response cache key +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct LLMCacheKey { + /// Prompt hash + pub prompt_hash: String, + /// Model name + pub model: String, + /// Temperature parameter (as bits to avoid f32 Hash/Eq issues) + pub temperature: Option, +} + +impl LLMCacheKey { + pub fn new(prompt: &str, model: &str, temperature: Option) -> Self { + use std::collections::hash_map::DefaultHasher; + use std::hash::Hasher; + + let mut hasher = DefaultHasher::new(); + prompt.hash(&mut hasher); + model.hash(&mut hasher); + if let Some(temp) = temperature { + temp.to_bits().hash(&mut hasher); + } + + Self { + prompt_hash: format!("{:x}", hasher.finish()), + model: model.to_string(), + temperature: temperature.map(|t| t.to_bits()), + } + } +} + +/// LLM response cache +pub type LLMCache = TTLCache; + +/// Create a new LLM cache with 1 hour TTL +pub fn create_llm_cache() -> LLMCache { + TTLCache::new(Duration::from_secs(3600)) +} + +/// API response cache key +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct ApiCacheKey { + /// Endpoint + pub endpoint: String, + /// Request hash + pub request_hash: String, +} + +impl ApiCacheKey { + pub fn new(endpoint: &str, request_body: &str) -> Self { + use std::collections::hash_map::DefaultHasher; + use std::hash::Hasher; + + let mut hasher = DefaultHasher::new(); + request_body.hash(&mut hasher); + + Self { + endpoint: endpoint.to_string(), + request_hash: format!("{:x}", hasher.finish()), + } + } +} + +/// API response cache +pub type ApiCache = TTLCache; + +/// Create a new API cache with 30 minutes TTL +pub fn create_api_cache() -> ApiCache { + TTLCache::new(Duration::from_secs(1800)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::sleep; + + #[tokio::test] + async fn test_ttl_cache_basic_operations() { + let cache = TTLCache::new(Duration::from_millis(100)); + + cache.insert("key1", "value1").await; + assert_eq!(cache.get(&"key1").await, Some("value1")); + + sleep(Duration::from_millis(150)).await; + assert_eq!(cache.get(&"key1").await, None); + } + + #[tokio::test] + async fn test_ttl_cache_cleanup() { + let cache = TTLCache::new(Duration::from_millis(50)); + + cache.insert("key1", "value1").await; + cache.insert("key2", "value2").await; + + sleep(Duration::from_millis(100)).await; + + let cleaned = cache.cleanup_expired().await; + assert_eq!(cleaned, 2); + + let stats = cache.stats().await; + assert_eq!(stats.total_entries, 0); + } + + #[tokio::test] + async fn test_llm_cache_key() { + let key1 = LLMCacheKey::new("prompt1", "model1", Some(0.7)); + let key2 = LLMCacheKey::new("prompt1", "model1", Some(0.7)); + let key3 = LLMCacheKey::new("prompt2", "model1", Some(0.7)); + + assert_eq!(key1, key2); + assert_ne!(key1, key3); + } +} diff --git a/crates/miyabi-core/src/error.rs b/crates/miyabi-core/src/error.rs index ea744e5..829ef2d 100644 --- a/crates/miyabi-core/src/error.rs +++ b/crates/miyabi-core/src/error.rs @@ -16,8 +16,85 @@ pub enum Error { #[error("Agent error: {0}")] Agent(String), + #[error("Git error: {0}")] + Git(String), + + #[error("HTTP error: {0}")] + Http(String), + + #[error("Timeout after {0}ms")] + Timeout(u64), + + #[error("GitHub error: {0}")] + GitHub(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Authentication error: {0}")] + Auth(String), + + #[error("Tool error: {0}")] + Tool(String), + + #[error("Permission denied: {0}")] + PermissionDenied(String), + #[error("{0}")] Other(String), } pub type Result = std::result::Result; + +/// Check if an error is retryable (transient) +impl Error { + pub fn is_retryable(&self) -> bool { + match self { + // Always retryable + Error::Timeout(_) => true, + + // HTTP errors - check message for transient patterns + Error::Http(msg) => { + let lower = msg.to_lowercase(); + lower.contains("timeout") + || lower.contains("connection") + || lower.contains("temporarily") + } + + // GitHub errors - check for rate limiting + Error::GitHub(msg) => { + let lower = msg.to_lowercase(); + lower.contains("rate limit") + || lower.contains("retry") + || lower.contains("temporarily unavailable") + } + + // IO errors - some kinds are retryable + Error::Io(io_error) => matches!( + io_error.kind(), + std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::WouldBlock + ), + + // Git errors - check message for lock conflicts + Error::Git(msg) => { + let lower = msg.to_lowercase(); + lower.contains("lock") || lower.contains("unable to create") + } + + // Never retryable + Error::Agent(_) => false, + Error::Auth(_) => false, + Error::Config(_) => false, + Error::Validation(_) => false, + Error::Json(_) => false, + Error::Tool(_) => false, + Error::PermissionDenied(_) => false, + Error::Other(_) => false, + } + } +} diff --git a/crates/miyabi-core/src/error_policy.rs b/crates/miyabi-core/src/error_policy.rs new file mode 100644 index 0000000..10831a5 --- /dev/null +++ b/crates/miyabi-core/src/error_policy.rs @@ -0,0 +1,291 @@ +//! Error handling policies for Miyabi system +//! +//! This module provides advanced error handling strategies: +//! - Circuit Breaker pattern for preventing cascading failures +//! - Fallback strategies for graceful degradation + +use crate::error::Error; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; + +/// Fallback strategy when execution fails +#[derive(Debug, Clone)] +pub enum FallbackStrategy { + /// Accept partial success + AcceptPartialSuccess { + /// Minimum number of successful operations required + min_successful: usize, + }, + /// Retry with lower temperature + RetryWithLowerTemperature { + /// Amount to reduce temperature by + temperature_reduction: f64, + }, + /// Switch to a different LLM model + SwitchModel { + /// Fallback model name + fallback_model: String, + }, + /// Wait for human intervention + WaitForHumanIntervention { + /// Timeout before giving up + timeout: Duration, + }, + /// Skip the task entirely + SkipTask, +} + +impl Default for FallbackStrategy { + fn default() -> Self { + Self::AcceptPartialSuccess { min_successful: 1 } + } +} + +impl FallbackStrategy { + /// Creates a partial success strategy with default threshold + pub fn partial_success() -> Self { + Self::AcceptPartialSuccess { min_successful: 1 } + } + + /// Creates a temperature reduction strategy + pub fn lower_temperature() -> Self { + Self::RetryWithLowerTemperature { + temperature_reduction: 0.2, + } + } + + /// Creates a model switch strategy + pub fn switch_to_claude() -> Self { + Self::SwitchModel { + fallback_model: "claude-sonnet-4-5-20250929".to_string(), + } + } + + /// Creates a human intervention strategy + pub fn wait_for_human() -> Self { + Self::WaitForHumanIntervention { + timeout: Duration::from_secs(24 * 60 * 60), + } + } +} + +/// State of the circuit breaker +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CircuitState { + /// Circuit is closed - requests flow normally + Closed, + /// Circuit is open - requests are blocked + Open, + /// Circuit is half-open - testing if service recovered + HalfOpen, +} + +/// Circuit breaker for preventing cascading failures +/// +/// The circuit breaker pattern prevents repeated attempts to execute +/// operations that are likely to fail, allowing the system to recover. +pub struct CircuitBreaker { + /// Number of consecutive failures before opening circuit + failure_threshold: usize, + /// Number of consecutive successes before closing circuit + success_threshold: usize, + /// Duration to wait before transitioning from Open to HalfOpen + timeout: Duration, + /// Current circuit state + state: Arc>, + /// Count of consecutive failures + consecutive_failures: Arc>, + /// Count of consecutive successes + consecutive_successes: Arc>, + /// Time when circuit was opened + opened_at: Arc>>, +} + +impl CircuitBreaker { + /// Creates a new CircuitBreaker + pub fn new(failure_threshold: usize, success_threshold: usize, timeout: Duration) -> Self { + Self { + failure_threshold, + success_threshold, + timeout, + state: Arc::new(Mutex::new(CircuitState::Closed)), + consecutive_failures: Arc::new(Mutex::new(0)), + consecutive_successes: Arc::new(Mutex::new(0)), + opened_at: Arc::new(Mutex::new(None)), + } + } + + /// Creates a CircuitBreaker with default settings + pub fn default_config() -> Self { + Self::new(5, 2, Duration::from_secs(60)) + } + + /// Executes the given operation through the circuit breaker + pub async fn call(&self, operation: F) -> Result + where + F: FnOnce() -> std::pin::Pin> + Send>>, + E: std::error::Error + Send + Sync + 'static, + { + // Check if we should attempt reset + if self.should_attempt_reset().await { + *self.state.lock().await = CircuitState::HalfOpen; + } + + let current_state = *self.state.lock().await; + + match current_state { + CircuitState::Open => Err(Error::Other("Circuit breaker is open".to_string())), + CircuitState::Closed | CircuitState::HalfOpen => match operation().await { + Ok(result) => { + self.on_success().await; + Ok(result) + } + Err(e) => { + self.on_failure().await; + Err(Error::Other(e.to_string())) + } + }, + } + } + + /// Records a successful operation + async fn on_success(&self) { + let mut successes = self.consecutive_successes.lock().await; + *successes += 1; + *self.consecutive_failures.lock().await = 0; + + if *successes >= self.success_threshold { + let mut state = self.state.lock().await; + if *state != CircuitState::Closed { + *state = CircuitState::Closed; + *self.opened_at.lock().await = None; + } + *successes = 0; + } + } + + /// Records a failed operation + async fn on_failure(&self) { + let mut failures = self.consecutive_failures.lock().await; + *failures += 1; + *self.consecutive_successes.lock().await = 0; + + if *failures >= self.failure_threshold { + let mut state = self.state.lock().await; + if *state == CircuitState::Closed { + *state = CircuitState::Open; + *self.opened_at.lock().await = Some(Instant::now()); + } + } + } + + /// Checks if circuit should attempt reset + async fn should_attempt_reset(&self) -> bool { + let state = *self.state.lock().await; + if state != CircuitState::Open { + return false; + } + + let opened_at = self.opened_at.lock().await; + if let Some(opened_time) = *opened_at { + opened_time.elapsed() >= self.timeout + } else { + false + } + } + + /// Gets the current circuit state + pub async fn state(&self) -> CircuitState { + *self.state.lock().await + } + + /// Gets the number of consecutive failures + pub async fn consecutive_failures(&self) -> usize { + *self.consecutive_failures.lock().await + } + + /// Gets the number of consecutive successes + pub async fn consecutive_successes(&self) -> usize { + *self.consecutive_successes.lock().await + } + + /// Resets the circuit breaker to closed state + pub async fn reset(&self) { + *self.state.lock().await = CircuitState::Closed; + *self.consecutive_failures.lock().await = 0; + *self.consecutive_successes.lock().await = 0; + *self.opened_at.lock().await = None; + } +} + +impl Default for CircuitBreaker { + fn default() -> Self { + Self::default_config() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_circuit_breaker_opens_after_failures() { + let breaker = CircuitBreaker::new(3, 2, Duration::from_millis(100)); + assert_eq!(breaker.state().await, CircuitState::Closed); + + for _ in 0..3 { + let result = breaker + .call(|| { + Box::pin(async { + Result::<(), std::io::Error>::Err(std::io::Error::other("test error")) + }) + }) + .await; + assert!(result.is_err()); + } + + assert_eq!(breaker.state().await, CircuitState::Open); + } + + #[tokio::test] + async fn test_circuit_breaker_blocks_when_open() { + let breaker = CircuitBreaker::new(2, 2, Duration::from_secs(60)); + + for _ in 0..2 { + let _ = breaker + .call(|| { + Box::pin(async { + Result::<(), std::io::Error>::Err(std::io::Error::other("error")) + }) + }) + .await; + } + + assert_eq!(breaker.state().await, CircuitState::Open); + + let result = breaker + .call(|| Box::pin(async { Ok::<(), std::io::Error>(()) })) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_circuit_breaker_reset() { + let breaker = CircuitBreaker::new(2, 2, Duration::from_secs(60)); + + for _ in 0..2 { + let _ = breaker + .call(|| { + Box::pin(async { + Result::<(), std::io::Error>::Err(std::io::Error::other("error")) + }) + }) + .await; + } + + assert_eq!(breaker.state().await, CircuitState::Open); + breaker.reset().await; + assert_eq!(breaker.state().await, CircuitState::Closed); + } +} diff --git a/crates/miyabi-core/src/feature_flags.rs b/crates/miyabi-core/src/feature_flags.rs new file mode 100644 index 0000000..3c4569f --- /dev/null +++ b/crates/miyabi-core/src/feature_flags.rs @@ -0,0 +1,330 @@ +//! Feature Flag Management System +//! +//! Provides a flexible feature flag system for gradual rollout and A/B testing. +//! +//! # Examples +//! +//! ```rust +//! use miyabi_core::feature_flags::FeatureFlagManager; +//! +//! let manager = FeatureFlagManager::new(); +//! manager.set_flag("new_architecture", true); +//! +//! if manager.is_enabled("new_architecture") { +//! // Use new architecture +//! } else { +//! // Use old architecture +//! } +//! ``` + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +/// Feature flag configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureFlag { + /// Flag name + pub name: String, + /// Enabled status + pub enabled: bool, + /// Optional description + pub description: Option, + /// Optional rollout percentage (0.0 to 1.0) + pub rollout_percentage: Option, +} + +/// Feature flag manager for controlling feature rollout +#[derive(Debug, Clone)] +pub struct FeatureFlagManager { + /// Internal flag storage (thread-safe) + flags: Arc>>, +} + +impl Default for FeatureFlagManager { + fn default() -> Self { + Self::new() + } +} + +impl FeatureFlagManager { + /// Create a new feature flag manager + pub fn new() -> Self { + Self { + flags: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Check if a feature flag is enabled + /// + /// # Arguments + /// + /// * `flag_name` - Name of the feature flag + /// + /// # Returns + /// + /// `true` if the flag exists and is enabled, `false` otherwise + pub fn is_enabled(&self, flag_name: &str) -> bool { + let flags = self.flags.read().unwrap(); + flags.get(flag_name).map(|flag| flag.enabled).unwrap_or(false) + } + + /// Set a feature flag + /// + /// # Arguments + /// + /// * `flag_name` - Name of the feature flag + /// * `enabled` - Whether the flag should be enabled + pub fn set_flag(&self, flag_name: impl Into, enabled: bool) { + let mut flags = self.flags.write().unwrap(); + let name = flag_name.into(); + flags.insert( + name.clone(), + FeatureFlag { + name, + enabled, + description: None, + rollout_percentage: None, + }, + ); + } + + /// Set a feature flag with description and rollout percentage + /// + /// # Arguments + /// + /// * `flag_name` - Name of the feature flag + /// * `enabled` - Whether the flag should be enabled + /// * `description` - Optional description of the flag + /// * `rollout_percentage` - Optional rollout percentage (0.0 to 1.0) + pub fn set_flag_with_options( + &self, + flag_name: impl Into, + enabled: bool, + description: Option, + rollout_percentage: Option, + ) { + let mut flags = self.flags.write().unwrap(); + let name = flag_name.into(); + flags.insert( + name.clone(), + FeatureFlag { + name, + enabled, + description, + rollout_percentage, + }, + ); + } + + /// Remove a feature flag + /// + /// # Arguments + /// + /// * `flag_name` - Name of the feature flag to remove + pub fn remove_flag(&self, flag_name: &str) { + let mut flags = self.flags.write().unwrap(); + flags.remove(flag_name); + } + + /// Get all feature flags + /// + /// # Returns + /// + /// A vector of all feature flags + pub fn get_all_flags(&self) -> Vec { + let flags = self.flags.read().unwrap(); + flags.values().cloned().collect() + } + + /// Get a specific feature flag + /// + /// # Arguments + /// + /// * `flag_name` - Name of the feature flag + /// + /// # Returns + /// + /// The feature flag if it exists, None otherwise + pub fn get_flag(&self, flag_name: &str) -> Option { + let flags = self.flags.read().unwrap(); + flags.get(flag_name).cloned() + } + + /// Load feature flags from a HashMap + /// + /// # Arguments + /// + /// * `config` - HashMap of flag names to enabled status + pub fn load_from_map(&self, config: HashMap) { + let mut flags = self.flags.write().unwrap(); + for (name, enabled) in config { + flags.insert( + name.clone(), + FeatureFlag { + name, + enabled, + description: None, + rollout_percentage: None, + }, + ); + } + } + + /// Load feature flags from detailed configuration + /// + /// # Arguments + /// + /// * `config` - Vector of FeatureFlag configurations + pub fn load_from_config(&self, config: Vec) { + let mut flags = self.flags.write().unwrap(); + for flag in config { + flags.insert(flag.name.clone(), flag); + } + } + + /// Export all flags to a HashMap + /// + /// # Returns + /// + /// HashMap of flag names to enabled status + pub fn export_to_map(&self) -> HashMap { + let flags = self.flags.read().unwrap(); + flags.iter().map(|(name, flag)| (name.clone(), flag.enabled)).collect() + } + + /// Clear all feature flags + pub fn clear(&self) { + let mut flags = self.flags.write().unwrap(); + flags.clear(); + } + + /// Get the number of feature flags + /// + /// # Returns + /// + /// The number of flags currently registered + pub fn count(&self) -> usize { + let flags = self.flags.read().unwrap(); + flags.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_flag() { + let manager = FeatureFlagManager::new(); + + // Initially disabled + assert!(!manager.is_enabled("new_feature")); + + // Enable flag + manager.set_flag("new_feature", true); + assert!(manager.is_enabled("new_feature")); + + // Disable flag + manager.set_flag("new_feature", false); + assert!(!manager.is_enabled("new_feature")); + } + + #[test] + fn test_flag_with_options() { + let manager = FeatureFlagManager::new(); + + manager.set_flag_with_options( + "beta_feature", + true, + Some("Beta testing feature".to_string()), + Some(0.5), + ); + + assert!(manager.is_enabled("beta_feature")); + + let flag = manager.get_flag("beta_feature").unwrap(); + assert_eq!(flag.description, Some("Beta testing feature".to_string())); + assert_eq!(flag.rollout_percentage, Some(0.5)); + } + + #[test] + fn test_load_from_map() { + let manager = FeatureFlagManager::new(); + + let mut config = HashMap::new(); + config.insert("flag1".to_string(), true); + config.insert("flag2".to_string(), false); + + manager.load_from_map(config); + + assert!(manager.is_enabled("flag1")); + assert!(!manager.is_enabled("flag2")); + assert_eq!(manager.count(), 2); + } + + #[test] + fn test_get_all_flags() { + let manager = FeatureFlagManager::new(); + + manager.set_flag("flag1", true); + manager.set_flag("flag2", false); + + let all_flags = manager.get_all_flags(); + assert_eq!(all_flags.len(), 2); + } + + #[test] + fn test_remove_flag() { + let manager = FeatureFlagManager::new(); + + manager.set_flag("temp_flag", true); + assert!(manager.is_enabled("temp_flag")); + + manager.remove_flag("temp_flag"); + assert!(!manager.is_enabled("temp_flag")); + assert_eq!(manager.count(), 0); + } + + #[test] + fn test_export_to_map() { + let manager = FeatureFlagManager::new(); + + manager.set_flag("flag1", true); + manager.set_flag("flag2", false); + + let exported = manager.export_to_map(); + assert_eq!(exported.get("flag1"), Some(&true)); + assert_eq!(exported.get("flag2"), Some(&false)); + } + + #[test] + fn test_clear() { + let manager = FeatureFlagManager::new(); + + manager.set_flag("flag1", true); + manager.set_flag("flag2", true); + assert_eq!(manager.count(), 2); + + manager.clear(); + assert_eq!(manager.count(), 0); + } + + #[test] + fn test_thread_safety() { + use std::thread; + + let manager = FeatureFlagManager::new(); + let manager_clone = manager.clone(); + + // Spawn thread to set flag + let handle = thread::spawn(move || { + manager_clone.set_flag("concurrent_flag", true); + }); + + handle.join().unwrap(); + + // Check flag is set + assert!(manager.is_enabled("concurrent_flag")); + } +} diff --git a/crates/miyabi-core/src/git.rs b/crates/miyabi-core/src/git.rs new file mode 100644 index 0000000..60e7a83 --- /dev/null +++ b/crates/miyabi-core/src/git.rs @@ -0,0 +1,211 @@ +//! Git utilities for repository discovery and validation +//! +//! Provides utilities for working with Git repositories: +//! - Repository root discovery from any subdirectory +//! - Repository validation +//! - Branch detection + +use crate::error::{Error, Result}; +use std::path::{Path, PathBuf}; + +/// Find the Git repository root from the current directory or any subdirectory +/// +/// This function uses Git's discovery mechanism to walk up the directory tree +/// until it finds a `.git` directory. +/// +/// # Examples +/// +/// ```no_run +/// use miyabi_core::git::find_git_root; +/// +/// let root = find_git_root(None)?; +/// println!("Git root: {:?}", root); +/// # Ok::<(), miyabi_core::error::Error>(()) +/// ``` +pub fn find_git_root(start_path: Option<&Path>) -> Result { + let search_path = match start_path { + Some(p) => p.to_path_buf(), + None => std::env::current_dir()?, + }; + + match git2::Repository::discover(&search_path) { + Ok(repo) => repo + .workdir() + .map(|p| p.to_path_buf()) + .ok_or_else(|| { + Error::Git( + "Repository is bare (no working directory). Miyabi requires a non-bare repository.".to_string() + ) + }), + Err(e) => Err(Error::Git(format!( + "Not in a Git repository. Please run miyabi from within a Git repository.\n\ + Searched from: {:?}\n\ + Git error: {}\n\n\ + To initialize a new repository, run: git init", + search_path, e + ))), + } +} + +/// Validate that a path is a valid Git repository +/// +/// # Arguments +/// * `path` - Path to validate +/// +/// # Returns +/// `true` if the path is a valid Git repository with a working directory +pub fn is_valid_repository(path: impl AsRef) -> bool { + match git2::Repository::open(path.as_ref()) { + Ok(repo) => repo.workdir().is_some(), + Err(_) => false, + } +} + +/// Check if a path is within a Git repository +/// +/// # Arguments +/// * `path` - Path to check +/// +/// # Returns +/// `true` if the path is within a Git repository +pub fn is_in_git_repo(path: impl AsRef) -> bool { + git2::Repository::discover(path.as_ref()).is_ok() +} + +/// Get the current branch name of a repository +/// +/// # Arguments +/// * `repo_path` - Path to the repository +/// +/// # Returns +/// The name of the currently checked out branch +/// +/// # Errors +/// Returns an error if the repository is in a detached HEAD state or cannot be opened +pub fn get_current_branch(repo_path: impl AsRef) -> Result { + let repo = git2::Repository::open(repo_path.as_ref()) + .map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?; + + let head = repo + .head() + .map_err(|e| Error::Git(format!("Failed to get HEAD: {}", e)))?; + + if !head.is_branch() { + return Err(Error::Git( + "Repository is in detached HEAD state. Please checkout a branch.".to_string(), + )); + } + + head.shorthand() + .map(|s| s.to_string()) + .ok_or_else(|| Error::Git("Failed to get branch name".to_string())) +} + +/// Get the main branch name (tries 'main' then 'master') +/// +/// # Arguments +/// * `repo_path` - Path to the repository +/// +/// # Returns +/// The name of the main branch ('main' or 'master') +/// +/// # Errors +/// Returns an error if neither 'main' nor 'master' branch exists +pub fn get_main_branch(repo_path: impl AsRef) -> Result { + let repo = git2::Repository::open(repo_path.as_ref()) + .map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?; + + if repo.find_branch("main", git2::BranchType::Local).is_ok() { + Ok("main".to_string()) + } else if repo.find_branch("master", git2::BranchType::Local).is_ok() { + Ok("master".to_string()) + } else { + Err(Error::Git( + "Neither 'main' nor 'master' branch found. Please create a main branch.".to_string(), + )) + } +} + +/// Check if the repository has uncommitted changes +/// +/// # Arguments +/// * `repo_path` - Path to the repository +/// +/// # Returns +/// `true` if there are uncommitted changes in the working directory or index +pub fn has_uncommitted_changes(repo_path: impl AsRef) -> Result { + let repo = git2::Repository::open(repo_path.as_ref()) + .map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?; + + let statuses = repo + .statuses(None) + .map_err(|e| Error::Git(format!("Failed to get repository status: {}", e)))?; + + Ok(!statuses.is_empty()) +} + +/// Get the short hash of HEAD +pub fn get_head_short_hash(repo_path: impl AsRef) -> Result { + let repo = git2::Repository::open(repo_path.as_ref()) + .map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?; + + let head = repo + .head() + .map_err(|e| Error::Git(format!("Failed to get HEAD: {}", e)))?; + + let commit = head + .peel_to_commit() + .map_err(|e| Error::Git(format!("Failed to get commit: {}", e)))?; + + Ok(commit.id().to_string()[..7].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_test_repo() -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + git2::Repository::init(&repo_path).unwrap(); + (temp_dir, repo_path) + } + + #[test] + fn test_find_git_root_from_root() { + let (_temp, repo_path) = setup_test_repo(); + let found_root = find_git_root(Some(&repo_path)).unwrap(); + let canonical_found = fs::canonicalize(&found_root).unwrap(); + let canonical_expected = fs::canonicalize(&repo_path).unwrap(); + assert_eq!(canonical_found, canonical_expected); + } + + #[test] + fn test_find_git_root_from_subdirectory() { + let (_temp, repo_path) = setup_test_repo(); + let sub_dir = repo_path.join("src").join("nested"); + fs::create_dir_all(&sub_dir).unwrap(); + let found_root = find_git_root(Some(&sub_dir)).unwrap(); + let canonical_found = fs::canonicalize(&found_root).unwrap(); + let canonical_expected = fs::canonicalize(&repo_path).unwrap(); + assert_eq!(canonical_found, canonical_expected); + } + + #[test] + fn test_is_valid_repository() { + let (_temp, repo_path) = setup_test_repo(); + assert!(is_valid_repository(&repo_path)); + let temp_dir = TempDir::new().unwrap(); + assert!(!is_valid_repository(temp_dir.path())); + } + + #[test] + fn test_is_in_git_repo() { + let (_temp, repo_path) = setup_test_repo(); + assert!(is_in_git_repo(&repo_path)); + let temp_dir = TempDir::new().unwrap(); + assert!(!is_in_git_repo(temp_dir.path())); + } +} diff --git a/crates/miyabi-core/src/hooks.rs b/crates/miyabi-core/src/hooks.rs new file mode 100644 index 0000000..a8bec17 --- /dev/null +++ b/crates/miyabi-core/src/hooks.rs @@ -0,0 +1,502 @@ +//! Hooks System for Event-Driven Execution +//! +//! Provides a system for registering and executing hooks based on events. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Hook event types +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookEvent { + /// Before sending a message + PreMessage, + /// After receiving a response + PostMessage, + /// Before executing a tool + PreTool, + /// After tool execution + PostTool, + /// Before committing changes + PreCommit, + /// After committing changes + PostCommit, + /// On session start + SessionStart, + /// On session end + SessionEnd, + /// On error + OnError, + /// Custom event + Custom(String), +} + +impl std::fmt::Display for HookEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HookEvent::PreMessage => write!(f, "pre_message"), + HookEvent::PostMessage => write!(f, "post_message"), + HookEvent::PreTool => write!(f, "pre_tool"), + HookEvent::PostTool => write!(f, "post_tool"), + HookEvent::PreCommit => write!(f, "pre_commit"), + HookEvent::PostCommit => write!(f, "post_commit"), + HookEvent::SessionStart => write!(f, "session_start"), + HookEvent::SessionEnd => write!(f, "session_end"), + HookEvent::OnError => write!(f, "on_error"), + HookEvent::Custom(name) => write!(f, "custom:{}", name), + } + } +} + +/// Hook action types +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HookAction { + /// Run a shell command + Shell { command: String }, + /// Run an agent with a prompt + Agent { + prompt: String, + #[serde(default)] + spec: Option, + }, + /// Send a notification + Notify { title: String, message: String }, + /// Log a message + Log { level: String, message: String }, + /// Execute a script file + Script { path: PathBuf }, +} + +/// A single hook definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hook { + /// Hook name for identification + pub name: String, + /// Event that triggers this hook + pub event: HookEvent, + /// Action to perform + pub action: HookAction, + /// Whether the hook is enabled + #[serde(default = "default_true")] + pub enabled: bool, + /// Optional condition (tool name, etc.) + #[serde(default)] + pub condition: Option, + /// Timeout in seconds + #[serde(default = "default_timeout")] + pub timeout: u64, +} + +fn default_true() -> bool { + true +} + +fn default_timeout() -> u64 { + 30 +} + +/// Hook execution context +#[derive(Debug, Clone, Default)] +pub struct HookContext { + /// Event-specific data + pub data: HashMap, + /// Tool name (for tool events) + pub tool_name: Option, + /// Tool result (for post_tool) + pub tool_result: Option, + /// Error message (for on_error) + pub error: Option, +} + +impl HookContext { + pub fn new() -> Self { + Self::default() + } + + pub fn with_tool(mut self, name: &str) -> Self { + self.tool_name = Some(name.to_string()); + self + } + + pub fn with_result(mut self, result: &str) -> Self { + self.tool_result = Some(result.to_string()); + self + } + + pub fn with_error(mut self, error: &str) -> Self { + self.error = Some(error.to_string()); + self + } + + pub fn with_data(mut self, key: &str, value: &str) -> Self { + self.data.insert(key.to_string(), value.to_string()); + self + } +} + +/// Hook execution result +#[derive(Debug)] +pub struct HookResult { + pub hook_name: String, + pub success: bool, + pub output: Option, + pub error: Option, +} + +/// Hooks configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HooksConfig { + /// List of hooks + #[serde(default)] + pub hooks: Vec, +} + +impl HooksConfig { + /// Load hooks from a YAML file + pub fn load(path: &PathBuf) -> Result { + let content = std::fs::read_to_string(path)?; + let config: HooksConfig = serde_yaml::from_str(&content)?; + Ok(config) + } + + /// Load hooks from default location (~/.miyabi/hooks.yml) + pub fn load_default() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let path = home.join(".miyabi").join("hooks.yml"); + if path.exists() { + Self::load(&path) + } else { + Ok(Self::default()) + } + } + + /// Save hooks to a file + pub fn save(&self, path: &PathBuf) -> Result<()> { + let content = serde_yaml::to_string(self)?; + std::fs::write(path, content)?; + Ok(()) + } +} + +/// Hook manager for registering and executing hooks +pub struct HookManager { + hooks: Vec, +} + +impl Default for HookManager { + fn default() -> Self { + Self::new() + } +} + +impl HookManager { + /// Create a new hook manager + pub fn new() -> Self { + Self { hooks: Vec::new() } + } + + /// Create from config + pub fn from_config(config: HooksConfig) -> Self { + Self { + hooks: config.hooks, + } + } + + /// Load hooks from default location + pub fn load_default() -> Result { + let config = HooksConfig::load_default()?; + Ok(Self::from_config(config)) + } + + /// Register a hook + pub fn register(&mut self, hook: Hook) { + self.hooks.push(hook); + } + + /// Get hooks for an event + pub fn get_hooks(&self, event: &HookEvent) -> Vec<&Hook> { + self.hooks + .iter() + .filter(|h| h.enabled && &h.event == event) + .collect() + } + + /// Execute hooks for an event + pub async fn execute(&self, event: &HookEvent, context: &HookContext) -> Vec { + let hooks = self.get_hooks(event); + let mut results = Vec::new(); + + for hook in hooks { + // Check condition + if let Some(condition) = &hook.condition { + if let Some(tool_name) = &context.tool_name { + if condition != tool_name { + continue; + } + } + } + + let result = self.execute_hook(hook, context).await; + results.push(result); + } + + results + } + + /// Execute a single hook + async fn execute_hook(&self, hook: &Hook, context: &HookContext) -> HookResult { + match &hook.action { + HookAction::Shell { command } => { + let expanded = self.expand_variables(command, context); + match self.run_shell_command(&expanded, hook.timeout).await { + Ok(output) => HookResult { + hook_name: hook.name.clone(), + success: true, + output: Some(output), + error: None, + }, + Err(e) => HookResult { + hook_name: hook.name.clone(), + success: false, + output: None, + error: Some(e.to_string()), + }, + } + } + HookAction::Notify { title, message } => { + let expanded_title = self.expand_variables(title, context); + let expanded_message = self.expand_variables(message, context); + // For now, just log the notification + tracing::info!("Hook notification: {} - {}", expanded_title, expanded_message); + HookResult { + hook_name: hook.name.clone(), + success: true, + output: Some(format!("{}: {}", expanded_title, expanded_message)), + error: None, + } + } + HookAction::Log { level, message } => { + let expanded = self.expand_variables(message, context); + match level.as_str() { + "error" => tracing::error!("Hook: {}", expanded), + "warn" => tracing::warn!("Hook: {}", expanded), + "info" => tracing::info!("Hook: {}", expanded), + "debug" => tracing::debug!("Hook: {}", expanded), + _ => tracing::info!("Hook: {}", expanded), + } + HookResult { + hook_name: hook.name.clone(), + success: true, + output: Some(expanded), + error: None, + } + } + HookAction::Script { path } => { + if !path.exists() { + return HookResult { + hook_name: hook.name.clone(), + success: false, + output: None, + error: Some(format!("Script not found: {:?}", path)), + }; + } + let command = path.to_string_lossy().to_string(); + match self.run_shell_command(&command, hook.timeout).await { + Ok(output) => HookResult { + hook_name: hook.name.clone(), + success: true, + output: Some(output), + error: None, + }, + Err(e) => HookResult { + hook_name: hook.name.clone(), + success: false, + output: None, + error: Some(e.to_string()), + }, + } + } + HookAction::Agent { prompt, spec: _ } => { + // Agent execution would require the full agent infrastructure + // For now, return a placeholder + let expanded = self.expand_variables(prompt, context); + HookResult { + hook_name: hook.name.clone(), + success: true, + output: Some(format!("Agent prompt: {}", expanded)), + error: None, + } + } + } + } + + /// Expand variables in a string + fn expand_variables(&self, input: &str, context: &HookContext) -> String { + let mut result = input.to_string(); + + // Replace context variables + if let Some(tool_name) = &context.tool_name { + result = result.replace("${tool_name}", tool_name); + } + if let Some(tool_result) = &context.tool_result { + result = result.replace("${tool_result}", tool_result); + } + if let Some(error) = &context.error { + result = result.replace("${error}", error); + } + + // Replace custom data + for (key, value) in &context.data { + result = result.replace(&format!("${{{}}}", key), value); + } + + result + } + + /// Run a shell command + async fn run_shell_command(&self, command: &str, timeout_secs: u64) -> Result { + use std::process::Stdio; + use tokio::process::Command; + use tokio::time::{timeout, Duration}; + + let output = timeout( + Duration::from_secs(timeout_secs), + Command::new("sh") + .arg("-c") + .arg(command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(), + ) + .await??; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow::anyhow!("Command failed: {}", stderr)) + } + } + + /// Get all registered hooks + pub fn list_hooks(&self) -> &[Hook] { + &self.hooks + } + + /// Enable a hook by name + pub fn enable(&mut self, name: &str) { + for hook in &mut self.hooks { + if hook.name == name { + hook.enabled = true; + } + } + } + + /// Disable a hook by name + pub fn disable(&mut self, name: &str) { + for hook in &mut self.hooks { + if hook.name == name { + hook.enabled = false; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_event_display() { + assert_eq!(HookEvent::PreMessage.to_string(), "pre_message"); + assert_eq!(HookEvent::PostTool.to_string(), "post_tool"); + assert_eq!( + HookEvent::Custom("test".to_string()).to_string(), + "custom:test" + ); + } + + #[test] + fn test_hook_context() { + let context = HookContext::new() + .with_tool("bash") + .with_result("success") + .with_data("key", "value"); + + assert_eq!(context.tool_name, Some("bash".to_string())); + assert_eq!(context.tool_result, Some("success".to_string())); + assert_eq!(context.data.get("key"), Some(&"value".to_string())); + } + + #[test] + fn test_expand_variables() { + let manager = HookManager::new(); + let context = HookContext::new() + .with_tool("bash") + .with_result("ok") + .with_data("name", "test"); + + let input = "Tool: ${tool_name}, Result: ${tool_result}, Name: ${name}"; + let expanded = manager.expand_variables(input, &context); + assert_eq!(expanded, "Tool: bash, Result: ok, Name: test"); + } + + #[test] + fn test_hook_config_serialization() { + let config = HooksConfig { + hooks: vec![Hook { + name: "test".to_string(), + event: HookEvent::PostTool, + action: HookAction::Log { + level: "info".to_string(), + message: "Tool completed".to_string(), + }, + enabled: true, + condition: Some("bash".to_string()), + timeout: 30, + }], + }; + + let yaml = serde_yaml::to_string(&config).unwrap(); + assert!(yaml.contains("post_tool")); + assert!(yaml.contains("bash")); + } + + #[test] + fn test_hook_manager_get_hooks() { + let mut manager = HookManager::new(); + manager.register(Hook { + name: "hook1".to_string(), + event: HookEvent::PreTool, + action: HookAction::Log { + level: "info".to_string(), + message: "test".to_string(), + }, + enabled: true, + condition: None, + timeout: 30, + }); + manager.register(Hook { + name: "hook2".to_string(), + event: HookEvent::PostTool, + action: HookAction::Log { + level: "info".to_string(), + message: "test".to_string(), + }, + enabled: true, + condition: None, + timeout: 30, + }); + + let pre_hooks = manager.get_hooks(&HookEvent::PreTool); + assert_eq!(pre_hooks.len(), 1); + assert_eq!(pre_hooks[0].name, "hook1"); + + let post_hooks = manager.get_hooks(&HookEvent::PostTool); + assert_eq!(post_hooks.len(), 1); + assert_eq!(post_hooks[0].name, "hook2"); + } +} diff --git a/crates/miyabi-core/src/lib.rs b/crates/miyabi-core/src/lib.rs index e565291..3b14089 100644 --- a/crates/miyabi-core/src/lib.rs +++ b/crates/miyabi-core/src/lib.rs @@ -13,15 +13,18 @@ pub mod feature_flags; pub mod git; pub mod github; pub mod github_tools; +pub mod hooks; pub mod logger; pub mod plugin; pub mod retry; pub mod rules; +pub mod mcp; pub mod session; pub mod token; pub mod tool; pub mod tools; pub mod types; +pub mod workflow; pub use agent::{ Agent, AgentConfig, AgentError, AgentEvent, AgentResult, ExecutorRegistry, RiskLevel, @@ -64,6 +67,7 @@ pub use github_tools::{ create_github_tool_registry, AddCommentTool, AddLabelsTool, CreateIssueTool, CreatePullRequestTool, GetIssueTool, ListIssuesTool, ListPullRequestsTool, }; +pub use hooks::{Hook, HookAction, HookContext, HookEvent, HookManager, HookResult, HooksConfig}; pub use logger::{init_logger, init_logger_with_config, LogFormat, LogLevel, LoggerConfig}; pub use plugin::{Plugin, PluginContext, PluginManager, PluginMetadata, PluginResult, PluginState}; pub use retry::{retry_with_backoff, RetryConfig as BackoffRetryConfig}; @@ -76,3 +80,10 @@ pub use tools::{ GrepTool, ReadTool, WriteTool, }; pub use types::*; +pub use workflow::{ + FailurePolicy, StepCondition, StepResult, StepStatus, Workflow, WorkflowContext, + WorkflowManager, WorkflowResult, WorkflowStatus, WorkflowStep, +}; +pub use mcp::{ + McpConfig, McpError, McpManager, McpRequest, McpResponse, McpServer, McpServerConfig, McpTool, +}; diff --git a/crates/miyabi-core/src/logger.rs b/crates/miyabi-core/src/logger.rs new file mode 100644 index 0000000..1f776eb --- /dev/null +++ b/crates/miyabi-core/src/logger.rs @@ -0,0 +1,158 @@ +//! Logging utilities for Miyabi +//! +//! Provides structured logging with multiple output formats + +use tracing_subscriber::{fmt, layer::SubscriberExt, prelude::*, EnvFilter}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogFormat { + /// Human-readable colored output + Pretty, + /// Compact format for CI/CD + Compact, + /// JSON format for structured parsing + Json, +} + +/// Logger configuration +#[derive(Debug, Clone)] +pub struct LoggerConfig { + /// Log level + pub level: LogLevel, + /// Log format + pub format: LogFormat, +} + +impl Default for LoggerConfig { + fn default() -> Self { + Self { + level: LogLevel::Info, + format: LogFormat::Pretty, + } + } +} + +impl From<&str> for LogLevel { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "trace" => LogLevel::Trace, + "debug" => LogLevel::Debug, + "info" => LogLevel::Info, + "warn" => LogLevel::Warn, + "error" => LogLevel::Error, + _ => LogLevel::Info, + } + } +} + +impl From for &'static str { + fn from(level: LogLevel) -> Self { + match level { + LogLevel::Trace => "trace", + LogLevel::Debug => "debug", + LogLevel::Info => "info", + LogLevel::Warn => "warn", + LogLevel::Error => "error", + } + } +} + +impl From<&str> for LogFormat { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "json" => LogFormat::Json, + "compact" => LogFormat::Compact, + _ => LogFormat::Pretty, + } + } +} + +/// Initialize logging with default configuration +pub fn init_logger(level: LogLevel) { + let config = LoggerConfig { + level, + ..Default::default() + }; + init_logger_with_config(config); +} + +/// Initialize logging with custom configuration +pub fn init_logger_with_config(config: LoggerConfig) { + let level_str: &'static str = config.level.into(); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level_str)); + + let subscriber = tracing_subscriber::registry().with(filter); + + match config.format { + LogFormat::Pretty => { + let fmt_layer = fmt::layer() + .pretty() + .with_target(true) + .with_thread_ids(false) + .with_line_number(true); + subscriber.with(fmt_layer).init(); + } + LogFormat::Compact => { + let fmt_layer = fmt::layer() + .compact() + .with_target(false) + .with_thread_ids(false); + subscriber.with(fmt_layer).init(); + } + LogFormat::Json => { + let fmt_layer = fmt::layer() + .json() + .with_current_span(true) + .with_span_list(true); + subscriber.with(fmt_layer).init(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_level_from_str() { + assert_eq!(LogLevel::from("trace"), LogLevel::Trace); + assert_eq!(LogLevel::from("debug"), LogLevel::Debug); + assert_eq!(LogLevel::from("info"), LogLevel::Info); + assert_eq!(LogLevel::from("warn"), LogLevel::Warn); + assert_eq!(LogLevel::from("error"), LogLevel::Error); + assert_eq!(LogLevel::from("invalid"), LogLevel::Info); + } + + #[test] + fn test_log_level_to_str() { + let level_str: &'static str = LogLevel::Trace.into(); + assert_eq!(level_str, "trace"); + + let level_str: &'static str = LogLevel::Info.into(); + assert_eq!(level_str, "info"); + } + + #[test] + fn test_log_format_from_str() { + assert_eq!(LogFormat::from("json"), LogFormat::Json); + assert_eq!(LogFormat::from("compact"), LogFormat::Compact); + assert_eq!(LogFormat::from("pretty"), LogFormat::Pretty); + assert_eq!(LogFormat::from("invalid"), LogFormat::Pretty); + } + + #[test] + fn test_logger_config_default() { + let config = LoggerConfig::default(); + assert_eq!(config.level, LogLevel::Info); + assert_eq!(config.format, LogFormat::Pretty); + } +} diff --git a/crates/miyabi-core/src/mcp.rs b/crates/miyabi-core/src/mcp.rs new file mode 100644 index 0000000..a6863ec --- /dev/null +++ b/crates/miyabi-core/src/mcp.rs @@ -0,0 +1,407 @@ +//! MCP (Model Context Protocol) Support +//! +//! Integration with external MCP tool servers. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; + +/// MCP server configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerConfig { + /// Server name + pub name: String, + /// Command to start the server + pub command: String, + /// Command arguments + #[serde(default)] + pub args: Vec, + /// Environment variables + #[serde(default)] + pub env: HashMap, + /// Whether to auto-start + #[serde(default = "default_true")] + pub auto_start: bool, +} + +fn default_true() -> bool { + true +} + +/// MCP servers configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct McpConfig { + /// List of server configurations + #[serde(default)] + pub servers: Vec, +} + +impl McpConfig { + /// Load configuration from a YAML file + pub fn load(path: &PathBuf) -> Result { + let content = std::fs::read_to_string(path)?; + let config: McpConfig = serde_yaml::from_str(&content)?; + Ok(config) + } + + /// Load from default location (~/.miyabi/mcp.yml) + pub fn load_default() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let path = home.join(".miyabi").join("mcp.yml"); + if path.exists() { + Self::load(&path) + } else { + Ok(Self::default()) + } + } + + /// Save configuration to a file + pub fn save(&self, path: &PathBuf) -> Result<()> { + let content = serde_yaml::to_string(self)?; + std::fs::write(path, content)?; + Ok(()) + } +} + +/// MCP JSON-RPC request +#[derive(Debug, Clone, Serialize)] +pub struct McpRequest { + pub jsonrpc: String, + pub id: u64, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// MCP JSON-RPC response +#[derive(Debug, Clone, Deserialize)] +pub struct McpResponse { + pub jsonrpc: String, + pub id: u64, + #[serde(default)] + pub result: Option, + #[serde(default)] + pub error: Option, +} + +/// MCP error +#[derive(Debug, Clone, Deserialize)] +pub struct McpError { + pub code: i32, + pub message: String, + #[serde(default)] + pub data: Option, +} + +/// MCP tool definition +#[derive(Debug, Clone, Deserialize)] +pub struct McpTool { + pub name: String, + pub description: String, + #[serde(default)] + pub input_schema: Option, +} + +/// MCP server connection +pub struct McpServer { + pub name: String, + process: Child, + request_id: u64, +} + +impl McpServer { + /// Start a new MCP server + pub async fn start(config: &McpServerConfig) -> Result { + let mut cmd = Command::new(&config.command); + cmd.args(&config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + for (key, value) in &config.env { + cmd.env(key, value); + } + + let process = cmd.spawn()?; + + Ok(Self { + name: config.name.clone(), + process, + request_id: 0, + }) + } + + /// Send a request and get a response + pub async fn request(&mut self, method: &str, params: Option) -> Result { + self.request_id += 1; + let request = McpRequest { + jsonrpc: "2.0".to_string(), + id: self.request_id, + method: method.to_string(), + params, + }; + + let stdin = self.process.stdin.as_mut() + .ok_or_else(|| anyhow::anyhow!("Failed to get stdin"))?; + let stdout = self.process.stdout.as_mut() + .ok_or_else(|| anyhow::anyhow!("Failed to get stdout"))?; + + // Send request + let request_json = serde_json::to_string(&request)?; + stdin.write_all(request_json.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await?; + + // Read response + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + reader.read_line(&mut line).await?; + + let response: McpResponse = serde_json::from_str(&line)?; + Ok(response) + } + + /// Initialize the server + pub async fn initialize(&mut self) -> Result { + let params = serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "clientInfo": { + "name": "miyabi", + "version": env!("CARGO_PKG_VERSION") + } + }); + + let response = self.request("initialize", Some(params)).await?; + if let Some(error) = response.error { + return Err(anyhow::anyhow!("MCP error: {}", error.message)); + } + + response.result.ok_or_else(|| anyhow::anyhow!("No result in response")) + } + + /// List available tools + pub async fn list_tools(&mut self) -> Result> { + let response = self.request("tools/list", None).await?; + if let Some(error) = response.error { + return Err(anyhow::anyhow!("MCP error: {}", error.message)); + } + + let result = response.result.ok_or_else(|| anyhow::anyhow!("No result"))?; + let tools: Vec = serde_json::from_value( + result.get("tools").cloned().unwrap_or(serde_json::Value::Array(vec![])) + )?; + + Ok(tools) + } + + /// Call a tool + pub async fn call_tool(&mut self, name: &str, arguments: serde_json::Value) -> Result { + let params = serde_json::json!({ + "name": name, + "arguments": arguments + }); + + let response = self.request("tools/call", Some(params)).await?; + if let Some(error) = response.error { + return Err(anyhow::anyhow!("MCP error: {}", error.message)); + } + + response.result.ok_or_else(|| anyhow::anyhow!("No result")) + } + + /// Shutdown the server + pub async fn shutdown(&mut self) -> Result<()> { + let _ = self.request("shutdown", None).await; + self.process.kill().await?; + Ok(()) + } +} + +/// MCP Manager for handling multiple servers +pub struct McpManager { + servers: HashMap, + config: McpConfig, +} + +impl McpManager { + /// Create a new MCP manager + pub fn new() -> Self { + Self { + servers: HashMap::new(), + config: McpConfig::default(), + } + } + + /// Create from configuration + pub fn with_config(config: McpConfig) -> Self { + Self { + servers: HashMap::new(), + config, + } + } + + /// Load configuration from default location + pub fn load_default() -> Result { + let config = McpConfig::load_default()?; + Ok(Self::with_config(config)) + } + + /// Start all configured servers + pub async fn start_all(&mut self) -> Result<()> { + let names: Vec<_> = self + .config + .servers + .iter() + .filter(|s| s.auto_start) + .map(|s| s.name.clone()) + .collect(); + + for name in names { + self.start_server(&name).await?; + } + Ok(()) + } + + /// Start a specific server + pub async fn start_server(&mut self, name: &str) -> Result<()> { + let config = self + .config + .servers + .iter() + .find(|s| s.name == name) + .ok_or_else(|| anyhow::anyhow!("Server not found: {}", name))? + .clone(); + + let mut server = McpServer::start(&config).await?; + server.initialize().await?; + self.servers.insert(name.to_string(), server); + Ok(()) + } + + /// Stop a specific server + pub async fn stop_server(&mut self, name: &str) -> Result<()> { + if let Some(mut server) = self.servers.remove(name) { + server.shutdown().await?; + } + Ok(()) + } + + /// Stop all servers + pub async fn stop_all(&mut self) -> Result<()> { + let names: Vec<_> = self.servers.keys().cloned().collect(); + for name in names { + self.stop_server(&name).await?; + } + Ok(()) + } + + /// Get a server by name + pub fn get_server(&mut self, name: &str) -> Option<&mut McpServer> { + self.servers.get_mut(name) + } + + /// List all available tools across all servers + pub async fn list_all_tools(&mut self) -> Result>> { + let mut all_tools = HashMap::new(); + let names: Vec<_> = self.servers.keys().cloned().collect(); + + for name in names { + if let Some(server) = self.servers.get_mut(&name) { + let tools = server.list_tools().await?; + all_tools.insert(name, tools); + } + } + + Ok(all_tools) + } + + /// Call a tool on a specific server + pub async fn call_tool( + &mut self, + server_name: &str, + tool_name: &str, + arguments: serde_json::Value, + ) -> Result { + let server = self + .get_server(server_name) + .ok_or_else(|| anyhow::anyhow!("Server not found: {}", server_name))?; + + server.call_tool(tool_name, arguments).await + } + + /// List running servers + pub fn list_servers(&self) -> Vec<&str> { + self.servers.keys().map(|s| s.as_str()).collect() + } + + /// Check if a server is running + pub fn is_running(&self, name: &str) -> bool { + self.servers.contains_key(name) + } +} + +impl Default for McpManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mcp_config_serialization() { + let config = McpConfig { + servers: vec![McpServerConfig { + name: "filesystem".to_string(), + command: "npx".to_string(), + args: vec!["@anthropic/mcp-server-filesystem".to_string()], + env: HashMap::new(), + auto_start: true, + }], + }; + + let yaml = serde_yaml::to_string(&config).unwrap(); + assert!(yaml.contains("filesystem")); + assert!(yaml.contains("npx")); + } + + #[test] + fn test_mcp_request_serialization() { + let request = McpRequest { + jsonrpc: "2.0".to_string(), + id: 1, + method: "tools/list".to_string(), + params: None, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("tools/list")); + assert!(!json.contains("params")); + } + + #[test] + fn test_mcp_manager() { + let config = McpConfig { + servers: vec![McpServerConfig { + name: "test".to_string(), + command: "echo".to_string(), + args: vec![], + env: HashMap::new(), + auto_start: false, + }], + }; + + let manager = McpManager::with_config(config); + assert!(!manager.is_running("test")); + assert_eq!(manager.list_servers().len(), 0); + } +} diff --git a/crates/miyabi-core/src/plugin.rs b/crates/miyabi-core/src/plugin.rs new file mode 100644 index 0000000..f14beb9 --- /dev/null +++ b/crates/miyabi-core/src/plugin.rs @@ -0,0 +1,408 @@ +//! Plugin system for Miyabi +//! +//! Provides a flexible plugin architecture for extending Miyabi functionality. +//! +//! # Example +//! +//! ```rust +//! use miyabi_core::plugin::{Plugin, PluginManager, PluginMetadata, PluginContext, PluginResult}; +//! use anyhow::Result; +//! +//! struct MyPlugin; +//! +//! impl Plugin for MyPlugin { +//! fn metadata(&self) -> PluginMetadata { +//! PluginMetadata { +//! name: "my-plugin".to_string(), +//! version: "1.0.0".to_string(), +//! description: Some("My custom plugin".to_string()), +//! author: Some("Miyabi Team".to_string()), +//! } +//! } +//! +//! fn init(&mut self) -> Result<()> { +//! println!("Plugin initialized!"); +//! Ok(()) +//! } +//! +//! fn execute(&self, context: &PluginContext) -> Result { +//! Ok(PluginResult { +//! success: true, +//! message: Some("Plugin executed successfully".to_string()), +//! data: None, +//! }) +//! } +//! } +//! ``` + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +/// Plugin metadata information +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PluginMetadata { + /// Plugin name (unique identifier) + pub name: String, + /// Semantic version + pub version: String, + /// Optional description + pub description: Option, + /// Optional author + pub author: Option, +} + +/// Plugin execution context +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginContext { + /// Plugin parameters + #[serde(default)] + pub params: HashMap, + /// Working directory + pub working_dir: Option, + /// Environment variables + #[serde(default)] + pub env: HashMap, +} + +/// Plugin execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginResult { + /// Execution success status + pub success: bool, + /// Optional result message + pub message: Option, + /// Optional result data + pub data: Option, +} + +/// Core plugin trait +/// +/// All plugins must implement this trait to be registered with the PluginManager. +pub trait Plugin: Send + Sync { + /// Returns plugin metadata + fn metadata(&self) -> PluginMetadata; + + /// Initializes the plugin + /// + /// Called once when the plugin is registered. + fn init(&mut self) -> Result<()>; + + /// Executes the plugin with the given context + fn execute(&self, context: &PluginContext) -> Result; + + /// Cleans up plugin resources + /// + /// Called when the plugin is unregistered or the manager is dropped. + fn shutdown(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Plugin lifecycle state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginState { + /// Plugin is registered but not initialized + Registered, + /// Plugin is initialized and ready + Initialized, + /// Plugin encountered an error + Error, + /// Plugin is shut down + Shutdown, +} + +/// Internal plugin wrapper +struct PluginEntry { + plugin: Box, + state: PluginState, +} + +/// Plugin manager +/// +/// Manages plugin registration, initialization, and execution. +#[derive(Clone)] +pub struct PluginManager { + plugins: Arc>>, +} + +impl PluginManager { + /// Creates a new plugin manager + pub fn new() -> Self { + Self { + plugins: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Registers a plugin + /// + /// # Errors + /// + /// Returns an error if: + /// - A plugin with the same name already exists + /// - Plugin initialization fails + pub fn register(&self, mut plugin: Box) -> Result<()> { + let metadata = plugin.metadata(); + let name = metadata.name.clone(); + + // Check if plugin already exists + { + let plugins = self.plugins.read().unwrap(); + if plugins.contains_key(&name) { + return Err(anyhow!("Plugin '{}' is already registered", name)); + } + } + + // Initialize plugin + plugin.init()?; + + // Store plugin + let mut plugins = self.plugins.write().unwrap(); + plugins.insert( + name.clone(), + PluginEntry { + plugin, + state: PluginState::Initialized, + }, + ); + + Ok(()) + } + + /// Unregisters a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin doesn't exist + pub fn unregister(&self, name: &str) -> Result<()> { + let mut plugins = self.plugins.write().unwrap(); + + let mut entry = + plugins.remove(name).ok_or_else(|| anyhow!("Plugin '{}' not found", name))?; + + entry.plugin.shutdown()?; + entry.state = PluginState::Shutdown; + + Ok(()) + } + + /// Executes a plugin with the given context + /// + /// # Errors + /// + /// Returns an error if: + /// - The plugin doesn't exist + /// - The plugin is not initialized + /// - Plugin execution fails + pub fn execute(&self, name: &str, context: &PluginContext) -> Result { + let plugins = self.plugins.read().unwrap(); + + let entry = plugins.get(name).ok_or_else(|| anyhow!("Plugin '{}' not found", name))?; + + if entry.state != PluginState::Initialized { + return Err(anyhow!("Plugin '{}' is not initialized (state: {:?})", name, entry.state)); + } + + entry.plugin.execute(context) + } + + /// Lists all registered plugins + pub fn list_plugins(&self) -> Vec { + let plugins = self.plugins.read().unwrap(); + plugins.values().map(|entry| entry.plugin.metadata()).collect() + } + + /// Gets plugin metadata + /// + /// # Errors + /// + /// Returns an error if the plugin doesn't exist + pub fn get_metadata(&self, name: &str) -> Result { + let plugins = self.plugins.read().unwrap(); + plugins + .get(name) + .map(|entry| entry.plugin.metadata()) + .ok_or_else(|| anyhow!("Plugin '{}' not found", name)) + } + + /// Gets plugin state + /// + /// # Errors + /// + /// Returns an error if the plugin doesn't exist + pub fn get_state(&self, name: &str) -> Result { + let plugins = self.plugins.read().unwrap(); + plugins + .get(name) + .map(|entry| entry.state) + .ok_or_else(|| anyhow!("Plugin '{}' not found", name)) + } + + /// Checks if a plugin exists + pub fn has_plugin(&self, name: &str) -> bool { + let plugins = self.plugins.read().unwrap(); + plugins.contains_key(name) + } + + /// Gets the number of registered plugins + pub fn count(&self) -> usize { + let plugins = self.plugins.read().unwrap(); + plugins.len() + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::new() + } +} + +impl Drop for PluginManager { + fn drop(&mut self) { + // Shutdown all plugins + if let Ok(mut plugins) = self.plugins.write() { + for (name, entry) in plugins.iter_mut() { + if let Err(e) = entry.plugin.shutdown() { + eprintln!("Error shutting down plugin '{}': {}", name, e); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestPlugin { + name: String, + initialized: bool, + } + + impl TestPlugin { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + initialized: false, + } + } + } + + impl Plugin for TestPlugin { + fn metadata(&self) -> PluginMetadata { + PluginMetadata { + name: self.name.clone(), + version: "1.0.0".to_string(), + description: Some("Test plugin".to_string()), + author: Some("Test Author".to_string()), + } + } + + fn init(&mut self) -> Result<()> { + self.initialized = true; + Ok(()) + } + + fn execute(&self, _context: &PluginContext) -> Result { + Ok(PluginResult { + success: true, + message: Some(format!("Plugin '{}' executed", self.name)), + data: None, + }) + } + } + + #[test] + fn test_register_plugin() { + let manager = PluginManager::new(); + let plugin = Box::new(TestPlugin::new("test-plugin")); + + assert!(manager.register(plugin).is_ok()); + assert_eq!(manager.count(), 1); + assert!(manager.has_plugin("test-plugin")); + } + + #[test] + fn test_duplicate_registration() { + let manager = PluginManager::new(); + let plugin1 = Box::new(TestPlugin::new("test-plugin")); + let plugin2 = Box::new(TestPlugin::new("test-plugin")); + + assert!(manager.register(plugin1).is_ok()); + assert!(manager.register(plugin2).is_err()); + } + + #[test] + fn test_execute_plugin() { + let manager = PluginManager::new(); + let plugin = Box::new(TestPlugin::new("test-plugin")); + + manager.register(plugin).unwrap(); + + let context = PluginContext::default(); + let result = manager.execute("test-plugin", &context).unwrap(); + + assert!(result.success); + assert!(result.message.is_some()); + } + + #[test] + fn test_unregister_plugin() { + let manager = PluginManager::new(); + let plugin = Box::new(TestPlugin::new("test-plugin")); + + manager.register(plugin).unwrap(); + assert_eq!(manager.count(), 1); + + manager.unregister("test-plugin").unwrap(); + assert_eq!(manager.count(), 0); + } + + #[test] + fn test_list_plugins() { + let manager = PluginManager::new(); + let plugin1 = Box::new(TestPlugin::new("plugin-1")); + let plugin2 = Box::new(TestPlugin::new("plugin-2")); + + manager.register(plugin1).unwrap(); + manager.register(plugin2).unwrap(); + + let plugins = manager.list_plugins(); + assert_eq!(plugins.len(), 2); + } + + #[test] + fn test_get_metadata() { + let manager = PluginManager::new(); + let plugin = Box::new(TestPlugin::new("test-plugin")); + + manager.register(plugin).unwrap(); + + let metadata = manager.get_metadata("test-plugin").unwrap(); + assert_eq!(metadata.name, "test-plugin"); + assert_eq!(metadata.version, "1.0.0"); + } + + #[test] + fn test_get_state() { + let manager = PluginManager::new(); + let plugin = Box::new(TestPlugin::new("test-plugin")); + + manager.register(plugin).unwrap(); + + let state = manager.get_state("test-plugin").unwrap(); + assert_eq!(state, PluginState::Initialized); + } + + #[test] + fn test_plugin_not_found() { + let manager = PluginManager::new(); + let context = PluginContext::default(); + + assert!(manager.execute("nonexistent", &context).is_err()); + assert!(manager.get_metadata("nonexistent").is_err()); + assert!(manager.get_state("nonexistent").is_err()); + assert!(manager.unregister("nonexistent").is_err()); + } +} diff --git a/crates/miyabi-core/src/retry.rs b/crates/miyabi-core/src/retry.rs new file mode 100644 index 0000000..919f435 --- /dev/null +++ b/crates/miyabi-core/src/retry.rs @@ -0,0 +1,212 @@ +//! Retry logic with exponential backoff +//! +//! Provides utilities for retrying transient failures with exponential backoff. +//! Useful for network operations, external API calls, and transient errors. + +use crate::error::{Error, Result}; +use std::future::Future; +use std::time::Duration; + +/// Configuration for retry behavior +#[derive(Debug, Clone)] +pub struct RetryConfig { + /// Maximum number of retry attempts (excluding the first attempt) + pub max_attempts: u32, + /// Initial delay before first retry (milliseconds) + pub initial_delay_ms: u64, + /// Maximum delay between retries (milliseconds) + pub max_delay_ms: u64, + /// Backoff multiplier (typically 2.0 for exponential backoff) + pub backoff_multiplier: f64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_attempts: 3, + initial_delay_ms: 100, + max_delay_ms: 30000, + backoff_multiplier: 2.0, + } + } +} + +impl RetryConfig { + /// Create a new retry configuration + pub fn new(max_attempts: u32, initial_delay_ms: u64, max_delay_ms: u64) -> Self { + Self { + max_attempts, + initial_delay_ms, + max_delay_ms, + backoff_multiplier: 2.0, + } + } + + /// Create aggressive retry config (5 attempts, 50ms initial, 10s max) + pub fn aggressive() -> Self { + Self { + max_attempts: 5, + initial_delay_ms: 50, + max_delay_ms: 10000, + backoff_multiplier: 2.0, + } + } + + /// Create conservative retry config (2 attempts, 500ms initial, 60s max) + pub fn conservative() -> Self { + Self { + max_attempts: 2, + initial_delay_ms: 500, + max_delay_ms: 60000, + backoff_multiplier: 2.0, + } + } + + /// Calculate delay for a given attempt number (0-indexed) + pub fn calculate_delay(&self, attempt: u32) -> Duration { + let delay_ms = (self.initial_delay_ms as f64 * self.backoff_multiplier.powi(attempt as i32)) + .min(self.max_delay_ms as f64) as u64; + Duration::from_millis(delay_ms) + } +} + +/// Retry a future with exponential backoff +/// +/// # Arguments +/// * `config` - Retry configuration +/// * `operation` - Async operation to retry (must be Fn, not FnOnce) +/// +/// # Returns +/// * `Ok(T)` - Operation succeeded +/// * `Err(Error)` - All retry attempts failed +/// +/// # Example +/// ```ignore +/// let result = retry_with_backoff( +/// RetryConfig::default(), +/// || async { fetch_data().await } +/// ).await?; +/// ``` +pub async fn retry_with_backoff(config: RetryConfig, operation: F) -> Result +where + F: Fn() -> Fut, + Fut: Future>, +{ + let mut last_error = None; + + for attempt in 0..=config.max_attempts { + match operation().await { + Ok(result) => { + return Ok(result); + } + Err(error) => { + // Check if error is retryable + if !error.is_retryable() { + return Err(error); + } + + last_error = Some(error); + + // Don't sleep after the last attempt + if attempt < config.max_attempts { + let delay = config.calculate_delay(attempt); + tokio::time::sleep(delay).await; + } + } + } + } + + // All attempts failed + Err(last_error.unwrap_or_else(|| Error::Other("No error captured".to_string()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + #[tokio::test] + async fn test_retry_config_default() { + let config = RetryConfig::default(); + assert_eq!(config.max_attempts, 3); + assert_eq!(config.initial_delay_ms, 100); + assert_eq!(config.max_delay_ms, 30000); + assert_eq!(config.backoff_multiplier, 2.0); + } + + #[tokio::test] + async fn test_calculate_delay_exponential() { + let config = RetryConfig::new(3, 100, 10000); + + assert_eq!(config.calculate_delay(0), Duration::from_millis(100)); + assert_eq!(config.calculate_delay(1), Duration::from_millis(200)); + assert_eq!(config.calculate_delay(2), Duration::from_millis(400)); + assert_eq!(config.calculate_delay(3), Duration::from_millis(800)); + } + + #[tokio::test] + async fn test_retry_succeeds_immediately() { + let attempt_count = Arc::new(AtomicU32::new(0)); + let attempt_count_clone = Arc::clone(&attempt_count); + + let config = RetryConfig::new(3, 10, 1000); + + let result = retry_with_backoff(config, || { + let count = Arc::clone(&attempt_count_clone); + async move { + count.fetch_add(1, Ordering::SeqCst); + Ok::("success".to_string()) + } + }) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "success"); + assert_eq!(attempt_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_retry_succeeds_after_failures() { + let attempt_count = Arc::new(AtomicU32::new(0)); + let attempt_count_clone = Arc::clone(&attempt_count); + + let config = RetryConfig::new(3, 10, 1000); + + let result = retry_with_backoff(config, || { + let count = Arc::clone(&attempt_count_clone); + async move { + let current = count.fetch_add(1, Ordering::SeqCst); + if current < 2 { + Err(Error::Timeout(1000)) + } else { + Ok::("success".to_string()) + } + } + }) + .await; + + assert!(result.is_ok()); + assert_eq!(attempt_count.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn test_retry_non_retryable_fails_immediately() { + let attempt_count = Arc::new(AtomicU32::new(0)); + let attempt_count_clone = Arc::clone(&attempt_count); + + let config = RetryConfig::new(3, 10, 1000); + + let result = retry_with_backoff(config, || { + let count = Arc::clone(&attempt_count_clone); + async move { + count.fetch_add(1, Ordering::SeqCst); + Err::(Error::Validation("Invalid".to_string())) + } + }) + .await; + + assert!(result.is_err()); + assert_eq!(attempt_count.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/miyabi-core/src/rules.rs b/crates/miyabi-core/src/rules.rs new file mode 100644 index 0000000..3b7a2e8 --- /dev/null +++ b/crates/miyabi-core/src/rules.rs @@ -0,0 +1,325 @@ +//! Project-specific rules support (.miyabirules) +//! +//! This module provides support for loading and applying custom rules from `.miyabirules` files. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// Error types for rules operations +#[derive(Error, Debug)] +pub enum RulesError { + /// File not found + #[error("Rules file not found: {0}")] + FileNotFound(PathBuf), + + /// Parse error + #[error("Failed to parse rules file: {0}")] + ParseError(String), + + /// Validation error + #[error("Validation error: {0}")] + ValidationError(String), + + /// I/O error + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +/// A single rule with pattern matching and suggestion +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Rule { + /// Rule name + pub name: String, + + /// Pattern to match (regex) + #[serde(default)] + pub pattern: Option, + + /// Suggestion message + pub suggestion: String, + + /// File extension filters (e.g., ["rs", "toml"]) + #[serde(default)] + pub file_extensions: Vec, + + /// Severity: "info", "warning", "error" + #[serde(default = "default_severity")] + pub severity: String, + + /// Whether this rule is enabled + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +fn default_severity() -> String { + "info".to_string() +} + +fn default_enabled() -> bool { + true +} + +impl Rule { + /// Check if this rule applies to a given file extension + pub fn applies_to_file(&self, file_path: &Path) -> bool { + if self.file_extensions.is_empty() { + return true; + } + + if let Some(ext) = file_path.extension() { + let ext_str = ext.to_string_lossy().to_string(); + self.file_extensions.iter().any(|e| e == &ext_str) + } else { + false + } + } + + /// Check if this rule matches a given line of code + pub fn matches(&self, line: &str) -> bool { + if let Some(pattern) = &self.pattern { + line.contains(pattern) + } else { + false + } + } +} + +/// Agent-specific preferences +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct AgentPreferences { + /// Code style preference + #[serde(default)] + pub style: Option, + + /// Error handling strategy + #[serde(default)] + pub error_handling: Option, + + /// Minimum quality score + #[serde(default)] + pub min_score: Option, + + /// Enable strict Clippy checks + #[serde(default)] + pub clippy_strict: Option, + + /// Custom agent-specific settings + #[serde(flatten)] + pub custom: HashMap, +} + +/// Root configuration structure for .miyabirules +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MiyabiRules { + /// Version of the rules format + #[serde(default = "default_version")] + pub version: u32, + + /// List of rules + #[serde(default)] + pub rules: Vec, + + /// Agent preferences by agent type + #[serde(default)] + pub agent_preferences: HashMap, + + /// Global settings + #[serde(default)] + pub settings: HashMap, +} + +fn default_version() -> u32 { + 1 +} + +impl Default for MiyabiRules { + fn default() -> Self { + Self { + version: 1, + rules: Vec::new(), + agent_preferences: HashMap::new(), + settings: HashMap::new(), + } + } +} + +impl MiyabiRules { + /// Create a new empty rules configuration + pub fn new() -> Self { + Self::default() + } + + /// Validate the rules configuration + pub fn validate(&self) -> Result<()> { + if self.version != 1 { + return Err(RulesError::ValidationError(format!( + "Unsupported version: {}. Only version 1 is supported.", + self.version + ))); + } + + for rule in &self.rules { + if rule.name.is_empty() { + return Err(RulesError::ValidationError( + "Rule name cannot be empty".to_string(), + )); + } + + if rule.suggestion.is_empty() { + return Err(RulesError::ValidationError(format!( + "Rule '{}' must have a suggestion", + rule.name + ))); + } + + match rule.severity.as_str() { + "info" | "warning" | "error" => {} + _ => { + return Err(RulesError::ValidationError(format!( + "Invalid severity '{}' for rule '{}'. Must be: info, warning, or error", + rule.severity, rule.name + ))) + } + } + } + + Ok(()) + } + + /// Get rules that apply to a specific file + pub fn rules_for_file(&self, file_path: &Path) -> Vec<&Rule> { + self.rules + .iter() + .filter(|r| r.enabled && r.applies_to_file(file_path)) + .collect() + } + + /// Get agent preferences for a specific agent type + pub fn get_agent_preferences(&self, agent_type: &str) -> Option<&AgentPreferences> { + self.agent_preferences.get(agent_type) + } + + /// Get a global setting value + pub fn get_setting(&self, key: &str) -> Option<&serde_json::Value> { + self.settings.get(key) + } +} + +/// Rules loader for loading .miyabirules files +pub struct RulesLoader { + /// Root directory to search for .miyabirules + root_dir: PathBuf, +} + +impl RulesLoader { + /// Create a new RulesLoader + pub fn new(root_dir: PathBuf) -> Self { + Self { root_dir } + } + + /// Find .miyabirules file in the directory hierarchy + pub fn find_rules_file(&self) -> Option { + let mut current = self.root_dir.clone(); + + loop { + let rules_path = current.join(".miyabirules"); + if rules_path.exists() { + return Some(rules_path); + } + + let rules_yaml = current.join(".miyabirules.yaml"); + if rules_yaml.exists() { + return Some(rules_yaml); + } + + let rules_yml = current.join(".miyabirules.yml"); + if rules_yml.exists() { + return Some(rules_yml); + } + + if !current.pop() { + break; + } + } + + None + } + + /// Load rules from .miyabirules file + pub fn load(&self) -> Result> { + let rules_path = match self.find_rules_file() { + Some(path) => path, + None => return Ok(None), + }; + + let content = fs::read_to_string(&rules_path)?; + + let rules: MiyabiRules = serde_yaml::from_str(&content).map_err(|e| { + RulesError::ParseError(format!("Failed to parse {}: {}", rules_path.display(), e)) + })?; + + rules.validate()?; + + Ok(Some(rules)) + } + + /// Load rules or return default if not found + pub fn load_or_default(&self) -> Result { + self.load().map(|opt| opt.unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_rule_applies_to_file() { + let rule = Rule { + name: "Rust rule".to_string(), + pattern: None, + suggestion: "Test".to_string(), + file_extensions: vec!["rs".to_string()], + severity: "info".to_string(), + enabled: true, + }; + + assert!(rule.applies_to_file(Path::new("main.rs"))); + assert!(!rule.applies_to_file(Path::new("main.py"))); + } + + #[test] + fn test_rule_matches() { + let rule = Rule { + name: "Test".to_string(), + pattern: Some("async".to_string()), + suggestion: "Test".to_string(), + file_extensions: vec![], + severity: "info".to_string(), + enabled: true, + }; + + assert!(rule.matches("async fn test() {}")); + assert!(!rule.matches("fn test() {}")); + } + + #[test] + fn test_rules_validation() { + let rules = MiyabiRules::default(); + assert!(rules.validate().is_ok()); + } + + #[test] + fn test_rules_loader_not_found() { + let temp_dir = TempDir::new().unwrap(); + let loader = RulesLoader::new(temp_dir.path().to_path_buf()); + let rules = loader.load().unwrap(); + assert!(rules.is_none()); + } +} diff --git a/crates/miyabi-core/src/workflow.rs b/crates/miyabi-core/src/workflow.rs new file mode 100644 index 0000000..447877d --- /dev/null +++ b/crates/miyabi-core/src/workflow.rs @@ -0,0 +1,557 @@ +//! Multi-Agent Workflow System +//! +//! Orchestrate multiple agents in sequence or parallel execution. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Workflow definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workflow { + /// Workflow name + pub name: String, + /// Description + #[serde(default)] + pub description: String, + /// Workflow steps + pub steps: Vec, + /// Global variables + #[serde(default)] + pub variables: HashMap, + /// On failure behavior + #[serde(default)] + pub on_failure: FailurePolicy, +} + +/// A single step in the workflow +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowStep { + /// Step ID for reference + pub id: String, + /// Step name + pub name: String, + /// Agent spec to use + #[serde(default)] + pub agent: Option, + /// Task/prompt for the agent + pub task: String, + /// Dependencies (step IDs that must complete first) + #[serde(default)] + pub depends_on: Vec, + /// Condition for execution + #[serde(default)] + pub condition: Option, + /// Timeout in seconds + #[serde(default = "default_step_timeout")] + pub timeout: u64, + /// Retry configuration + #[serde(default)] + pub retry: RetryConfig, + /// Output variable name to store result + #[serde(default)] + pub output: Option, +} + +fn default_step_timeout() -> u64 { + 300 +} + +/// Condition for step execution +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StepCondition { + /// Always execute + Always, + /// Execute if previous step succeeded + OnSuccess, + /// Execute if previous step failed + OnFailure, + /// Execute if expression is true + If { expression: String }, +} + +impl Default for StepCondition { + fn default() -> Self { + StepCondition::Always + } +} + +/// Retry configuration for a step +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RetryConfig { + /// Maximum retry attempts + #[serde(default = "default_max_retries")] + pub max_attempts: u32, + /// Delay between retries in seconds + #[serde(default = "default_retry_delay")] + pub delay: u64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_attempts: 1, + delay: 5, + } + } +} + +fn default_max_retries() -> u32 { + 1 +} + +fn default_retry_delay() -> u64 { + 5 +} + +/// Failure policy for workflow +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailurePolicy { + /// Stop workflow on first failure + Stop, + /// Continue with remaining steps + Continue, + /// Run cleanup steps + Cleanup, +} + +impl Default for FailurePolicy { + fn default() -> Self { + FailurePolicy::Stop + } +} + +/// Step execution result +#[derive(Debug, Clone)] +pub struct StepResult { + pub step_id: String, + pub status: StepStatus, + pub output: Option, + pub error: Option, + pub duration_ms: u64, +} + +/// Step execution status +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StepStatus { + Pending, + Running, + Completed, + Failed, + Skipped, +} + +/// Workflow execution context +#[derive(Debug, Clone, Default)] +pub struct WorkflowContext { + /// Variables available to steps + pub variables: HashMap, + /// Results from completed steps + pub results: HashMap, +} + +impl WorkflowContext { + pub fn new() -> Self { + Self::default() + } + + pub fn with_variables(mut self, vars: HashMap) -> Self { + self.variables = vars; + self + } + + pub fn set_variable(&mut self, key: &str, value: &str) { + self.variables.insert(key.to_string(), value.to_string()); + } + + pub fn get_variable(&self, key: &str) -> Option<&String> { + self.variables.get(key) + } + + pub fn add_result(&mut self, result: StepResult) { + self.results.insert(result.step_id.clone(), result); + } + + pub fn get_result(&self, step_id: &str) -> Option<&StepResult> { + self.results.get(step_id) + } + + /// Expand variables in a string + pub fn expand(&self, input: &str) -> String { + let mut result = input.to_string(); + for (key, value) in &self.variables { + result = result.replace(&format!("${{{}}}", key), value); + } + result + } +} + +/// Workflow execution result +#[derive(Debug)] +pub struct WorkflowResult { + pub workflow_name: String, + pub status: WorkflowStatus, + pub steps: Vec, + pub duration_ms: u64, +} + +/// Workflow execution status +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkflowStatus { + Completed, + Failed, + PartiallyCompleted, +} + +/// Workflow manager for loading and executing workflows +pub struct WorkflowManager { + workflows: HashMap, +} + +impl Default for WorkflowManager { + fn default() -> Self { + Self::new() + } +} + +impl WorkflowManager { + /// Create a new workflow manager + pub fn new() -> Self { + Self { + workflows: HashMap::new(), + } + } + + /// Load a workflow from a YAML file + pub fn load_workflow(&mut self, path: &PathBuf) -> Result { + let content = std::fs::read_to_string(path)?; + let workflow: Workflow = serde_yaml::from_str(&content)?; + let name = workflow.name.clone(); + self.workflows.insert(name.clone(), workflow); + Ok(name) + } + + /// Load all workflows from a directory + pub fn load_from_directory(&mut self, dir: &PathBuf) -> Result> { + let mut loaded = Vec::new(); + if dir.exists() && dir.is_dir() { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "yml" || e == "yaml").unwrap_or(false) { + if let Ok(name) = self.load_workflow(&path) { + loaded.push(name); + } + } + } + } + Ok(loaded) + } + + /// Register a workflow directly + pub fn register(&mut self, workflow: Workflow) { + self.workflows.insert(workflow.name.clone(), workflow); + } + + /// Get a workflow by name + pub fn get_workflow(&self, name: &str) -> Option<&Workflow> { + self.workflows.get(name) + } + + /// List all registered workflows + pub fn list_workflows(&self) -> Vec<&str> { + self.workflows.keys().map(|s| s.as_str()).collect() + } + + /// Execute a workflow + pub async fn execute(&self, name: &str, initial_vars: HashMap) -> Result { + let workflow = self + .workflows + .get(name) + .ok_or_else(|| anyhow::anyhow!("Workflow not found: {}", name))?; + + let start_time = std::time::Instant::now(); + let mut context = WorkflowContext::new().with_variables(workflow.variables.clone()); + + // Add initial variables + for (key, value) in initial_vars { + context.set_variable(&key, &value); + } + + let mut step_results = Vec::new(); + let mut all_succeeded = true; + + // Build dependency graph and execute + let execution_order = self.build_execution_order(workflow)?; + + for step_id in execution_order { + let step = workflow + .steps + .iter() + .find(|s| s.id == step_id) + .ok_or_else(|| anyhow::anyhow!("Step not found: {}", step_id))?; + + // Check dependencies + let deps_satisfied = step.depends_on.iter().all(|dep_id| { + context + .get_result(dep_id) + .map(|r| r.status == StepStatus::Completed) + .unwrap_or(false) + }); + + if !deps_satisfied { + let result = StepResult { + step_id: step.id.clone(), + status: StepStatus::Skipped, + output: None, + error: Some("Dependencies not satisfied".to_string()), + duration_ms: 0, + }; + step_results.push(result.clone()); + context.add_result(result); + continue; + } + + // Check condition + if !self.evaluate_condition(&step.condition, &context) { + let result = StepResult { + step_id: step.id.clone(), + status: StepStatus::Skipped, + output: None, + error: Some("Condition not met".to_string()), + duration_ms: 0, + }; + step_results.push(result.clone()); + context.add_result(result); + continue; + } + + // Execute step + let step_start = std::time::Instant::now(); + let expanded_task = context.expand(&step.task); + + // Placeholder for actual agent execution + let result = StepResult { + step_id: step.id.clone(), + status: StepStatus::Completed, + output: Some(format!("Executed: {}", expanded_task)), + error: None, + duration_ms: step_start.elapsed().as_millis() as u64, + }; + + // Store output variable if specified + if let Some(output_var) = &step.output { + if let Some(output) = &result.output { + context.set_variable(output_var, output); + } + } + + if result.status == StepStatus::Failed { + all_succeeded = false; + if matches!(workflow.on_failure, FailurePolicy::Stop) { + step_results.push(result.clone()); + context.add_result(result); + break; + } + } + + step_results.push(result.clone()); + context.add_result(result); + } + + let status = if all_succeeded { + WorkflowStatus::Completed + } else if step_results.iter().any(|r| r.status == StepStatus::Completed) { + WorkflowStatus::PartiallyCompleted + } else { + WorkflowStatus::Failed + }; + + Ok(WorkflowResult { + workflow_name: name.to_string(), + status, + steps: step_results, + duration_ms: start_time.elapsed().as_millis() as u64, + }) + } + + /// Build execution order from dependencies (topological sort) + fn build_execution_order(&self, workflow: &Workflow) -> Result> { + let mut order = Vec::new(); + let mut visited = HashMap::new(); + let mut temp_visited = HashMap::new(); + + for step in &workflow.steps { + if !visited.contains_key(&step.id) { + self.visit_step(workflow, &step.id, &mut visited, &mut temp_visited, &mut order)?; + } + } + + Ok(order) + } + + fn visit_step( + &self, + workflow: &Workflow, + step_id: &str, + visited: &mut HashMap, + temp_visited: &mut HashMap, + order: &mut Vec, + ) -> Result<()> { + if temp_visited.get(step_id).copied().unwrap_or(false) { + return Err(anyhow::anyhow!("Circular dependency detected at step: {}", step_id)); + } + + if visited.get(step_id).copied().unwrap_or(false) { + return Ok(()); + } + + temp_visited.insert(step_id.to_string(), true); + + let step = workflow + .steps + .iter() + .find(|s| s.id == step_id) + .ok_or_else(|| anyhow::anyhow!("Step not found: {}", step_id))?; + + for dep_id in &step.depends_on { + self.visit_step(workflow, dep_id, visited, temp_visited, order)?; + } + + temp_visited.insert(step_id.to_string(), false); + visited.insert(step_id.to_string(), true); + order.push(step_id.to_string()); + + Ok(()) + } + + /// Evaluate a step condition + fn evaluate_condition(&self, condition: &Option, context: &WorkflowContext) -> bool { + match condition { + None | Some(StepCondition::Always) => true, + Some(StepCondition::OnSuccess) => context + .results + .values() + .last() + .map(|r| r.status == StepStatus::Completed) + .unwrap_or(true), + Some(StepCondition::OnFailure) => context + .results + .values() + .last() + .map(|r| r.status == StepStatus::Failed) + .unwrap_or(false), + Some(StepCondition::If { expression }) => { + // Simple expression evaluation (variable == value) + let expanded = context.expand(expression); + !expanded.is_empty() && expanded != "false" && expanded != "0" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workflow_context() { + let mut context = WorkflowContext::new(); + context.set_variable("name", "test"); + context.set_variable("version", "1.0"); + + assert_eq!(context.get_variable("name"), Some(&"test".to_string())); + assert_eq!( + context.expand("Project: ${name} v${version}"), + "Project: test v1.0" + ); + } + + #[test] + fn test_workflow_serialization() { + let workflow = Workflow { + name: "test-workflow".to_string(), + description: "Test".to_string(), + steps: vec![ + WorkflowStep { + id: "step1".to_string(), + name: "First Step".to_string(), + agent: Some("code-reviewer".to_string()), + task: "Review code".to_string(), + depends_on: vec![], + condition: None, + timeout: 300, + retry: RetryConfig::default(), + output: Some("review_result".to_string()), + }, + ], + variables: HashMap::new(), + on_failure: FailurePolicy::Stop, + }; + + let yaml = serde_yaml::to_string(&workflow).unwrap(); + assert!(yaml.contains("test-workflow")); + assert!(yaml.contains("step1")); + } + + #[test] + fn test_workflow_manager() { + let mut manager = WorkflowManager::new(); + let workflow = Workflow { + name: "test".to_string(), + description: "Test workflow".to_string(), + steps: vec![], + variables: HashMap::new(), + on_failure: FailurePolicy::Stop, + }; + + manager.register(workflow); + assert!(manager.get_workflow("test").is_some()); + assert_eq!(manager.list_workflows(), vec!["test"]); + } + + #[tokio::test] + async fn test_workflow_execution() { + let mut manager = WorkflowManager::new(); + let workflow = Workflow { + name: "simple".to_string(), + description: "Simple workflow".to_string(), + steps: vec![ + WorkflowStep { + id: "step1".to_string(), + name: "Step 1".to_string(), + agent: None, + task: "Task 1".to_string(), + depends_on: vec![], + condition: None, + timeout: 30, + retry: RetryConfig::default(), + output: None, + }, + WorkflowStep { + id: "step2".to_string(), + name: "Step 2".to_string(), + agent: None, + task: "Task 2 depends on ${result}".to_string(), + depends_on: vec!["step1".to_string()], + condition: None, + timeout: 30, + retry: RetryConfig::default(), + output: None, + }, + ], + variables: HashMap::new(), + on_failure: FailurePolicy::Stop, + }; + + manager.register(workflow); + let result = manager.execute("simple", HashMap::new()).await.unwrap(); + + assert_eq!(result.workflow_name, "simple"); + assert_eq!(result.status, WorkflowStatus::Completed); + assert_eq!(result.steps.len(), 2); + } +} diff --git a/crates/miyabi-tui/Cargo.toml b/crates/miyabi-tui/Cargo.toml index cdbf450..233df6b 100644 --- a/crates/miyabi-tui/Cargo.toml +++ b/crates/miyabi-tui/Cargo.toml @@ -50,4 +50,5 @@ once_cell = { workspace = true } uuid = { workspace = true } [features] +default = ["clipboard"] clipboard = ["arboard"] diff --git a/crates/miyabi-tui/src/app.rs b/crates/miyabi-tui/src/app.rs index 9a3f482..d943fd5 100644 --- a/crates/miyabi-tui/src/app.rs +++ b/crates/miyabi-tui/src/app.rs @@ -15,6 +15,7 @@ use crate::history_cell::{ use crate::views::{MainView, ViewAction}; use miyabi_core::anthropic::{AnthropicClient, ContentBlock, Message, StreamEvent}; use miyabi_core::config::Config; +use miyabi_core::rules::{MiyabiRules, RulesLoader}; use miyabi_core::session::{Session, SessionStorage}; use miyabi_core::tool::ToolRegistry; use miyabi_core::tools::create_standard_tool_registry; @@ -72,6 +73,8 @@ pub struct App { max_tokens: u32, /// Whether to request extended thinking thinking: bool, + /// Project rules loaded from .miyabirules + rules: Option, } impl App { @@ -135,6 +138,27 @@ impl App { let max_tokens = config.api.max_tokens; let thinking = config.api.thinking; + // Load project rules from .miyabirules + let cwd = std::env::current_dir().unwrap_or_default(); + let loader = RulesLoader::new(cwd); + let rules = match loader.load() { + Ok(Some(rules)) => { + let count = rules.rules.len(); + if count > 0 { + view.notifications.info( + "Rules Loaded", + format!("{} project rules active", count), + ); + } + Some(rules) + } + Ok(None) => None, + Err(e) => { + view.notifications.error("Rules Error", format!("Failed to load: {}", e)); + None + } + }; + Self { should_quit: false, view, @@ -151,9 +175,15 @@ impl App { model_name, max_tokens, thinking, + rules, } } + /// Get the loaded project rules + pub fn get_rules(&self) -> Option<&MiyabiRules> { + self.rules.as_ref() + } + /// Toggle agent mode pub fn toggle_agent_mode(&mut self) { self.agent_mode = !self.agent_mode; @@ -241,18 +271,106 @@ impl App { self.view.notifications.panel.push(notification); } ViewAction::Copy(text) => { - // TODO: Implement clipboard support - self.view - .notifications - .info("Copied", format!("{} chars", text.len())); + #[cfg(feature = "clipboard")] + { + match arboard::Clipboard::new() { + Ok(mut clipboard) => { + if let Err(e) = clipboard.set_text(&text) { + self.view + .notifications + .error("Clipboard Error", e.to_string()); + } else { + self.view + .notifications + .info("Copied", format!("{} chars", text.len())); + } + } + Err(e) => { + self.view + .notifications + .error("Clipboard Error", e.to_string()); + } + } + } + #[cfg(not(feature = "clipboard"))] + { + self.view + .notifications + .warning("Clipboard", "Clipboard feature not enabled"); + let _ = text; // Suppress unused warning + } } ViewAction::OpenFile(path) => { - // TODO: Implement file opening - self.view.notifications.info("Open File", &path); + // Open file with system default application + #[cfg(target_os = "macos")] + let result = std::process::Command::new("open").arg(&path).spawn(); + #[cfg(target_os = "linux")] + let result = std::process::Command::new("xdg-open").arg(&path).spawn(); + #[cfg(target_os = "windows")] + let result = std::process::Command::new("cmd") + .args(["/C", "start", "", &path]) + .spawn(); + + match result { + Ok(_) => { + self.view.notifications.info("Opened", &path); + } + Err(e) => { + self.view + .notifications + .error("Open Error", e.to_string()); + } + } } ViewAction::ResumeSession(session_id) => { - // TODO: Implement session resume - self.view.notifications.info("Resume Session", &session_id); + match self.load_session(&session_id) { + Ok(()) => { + // Rebuild UI from loaded conversation + self.view.history.clear(); + let timestamp = + chrono::Local::now().format("%H:%M").to_string(); + + // Replay conversation into UI + for msg in &self.conversation { + match msg.role { + miyabi_core::anthropic::Role::User => { + if let Some(ContentBlock::Text { text }) = + msg.content.first() + { + self.view.push_message(Box::new( + UserMessageCell { + content: text.clone(), + timestamp: timestamp.clone(), + }, + )); + } + } + miyabi_core::anthropic::Role::Assistant => { + if let Some(ContentBlock::Text { text }) = + msg.content.first() + { + let mut cell = AssistantMessageCell::new( + timestamp.clone(), + ); + cell.set_content(text); + cell.set_complete(); + self.view.push_message(Box::new(cell)); + } + } + } + } + + self.view.notifications.success( + "Session Resumed", + format!("{} messages loaded", self.conversation.len()), + ); + } + Err(e) => { + self.view + .notifications + .error("Resume Error", e.to_string()); + } + } } ViewAction::ToggleAgentMode => { self.toggle_agent_mode(); @@ -815,7 +933,7 @@ impl App { match client .message_stream( self.conversation.clone(), - Some("You are a helpful AI assistant. Be concise and clear.".to_string()), + self.system_prompt.clone(), None, None, )