Skip to main content

chub_core/team/
project.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::SourceConfig;
7use crate::error::{Error, Result};
8
9/// Configuration for the Tier 3 hosted annotation server.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AnnotationServerConfig {
12    /// Base URL of the annotation server (e.g. "https://annotations.company.com").
13    pub url: String,
14    /// When true, every Tier 2 (team) write is also pushed to the org server.
15    #[serde(default)]
16    pub auto_push: bool,
17    /// How long (seconds) to cache org annotations locally. Default 3600 (1 hour).
18    #[serde(default)]
19    pub cache_ttl_secs: Option<u64>,
20}
21
22/// Search upward from CWD (or a given path) for a `.chub/` directory.
23/// If `CHUB_PROJECT_DIR` env var is set, uses that directly (useful for testing).
24pub fn find_project_root(start: Option<&Path>) -> Option<PathBuf> {
25    // Allow override via env var (for testing and advanced use)
26    if let Ok(dir) = std::env::var("CHUB_PROJECT_DIR") {
27        let path = PathBuf::from(dir);
28        if path.join(".chub").is_dir() {
29            return Some(path);
30        }
31    }
32
33    let start = start
34        .map(|p| p.to_path_buf())
35        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
36
37    let mut current = start.as_path();
38    loop {
39        let candidate = current.join(".chub");
40        if candidate.is_dir() {
41            return Some(current.to_path_buf());
42        }
43        current = current.parent()?;
44    }
45}
46
47/// Get the `.chub/` directory for the current project (if any).
48pub fn project_chub_dir() -> Option<PathBuf> {
49    find_project_root(None).map(|root| root.join(".chub"))
50}
51
52/// Project-level config that extends the global config.
53#[derive(Debug, Clone, Default, Deserialize)]
54pub struct ProjectFileConfig {
55    #[serde(default)]
56    pub sources: Option<Vec<SourceConfig>>,
57    #[serde(default)]
58    pub cdn_url: Option<String>,
59    #[serde(default)]
60    pub output_dir: Option<String>,
61    #[serde(default)]
62    pub refresh_interval: Option<u64>,
63    #[serde(default)]
64    pub output_format: Option<String>,
65    #[serde(default)]
66    pub source: Option<String>,
67    #[serde(default)]
68    pub telemetry: Option<bool>,
69    #[serde(default)]
70    pub feedback: Option<bool>,
71    #[serde(default)]
72    pub telemetry_url: Option<String>,
73    #[serde(default)]
74    pub agent_rules: Option<AgentRules>,
75    #[serde(default)]
76    pub auto_profile: Option<Vec<AutoProfileEntry>>,
77    #[serde(default)]
78    pub annotation_server: Option<AnnotationServerConfig>,
79}
80
81/// Agent rules configuration for generating CLAUDE.md, .cursorrules, etc.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct AgentRules {
84    #[serde(default)]
85    pub global: Vec<String>,
86    #[serde(default)]
87    pub modules: std::collections::HashMap<String, ModuleRules>,
88    #[serde(default)]
89    pub include_pins: bool,
90    #[serde(default)]
91    pub include_context: bool,
92    /// Emit an Annotation Policy section instructing agents when and how to write back
93    /// structured annotations (issue / fix / practice) as they discover knowledge.
94    #[serde(default)]
95    pub include_annotation_policy: bool,
96    #[serde(default)]
97    pub targets: Vec<String>,
98}
99
100/// Rules scoped to a module/path pattern.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct ModuleRules {
103    pub path: String,
104    #[serde(default)]
105    pub rules: Vec<String>,
106}
107
108/// Auto-profile entry: maps a path glob to a profile name.
109#[derive(Debug, Clone, Deserialize, Serialize)]
110pub struct AutoProfileEntry {
111    pub path: String,
112    pub profile: String,
113}
114
115/// Load the project-level config from `.chub/config.yaml`.
116pub fn load_project_config() -> Option<ProjectFileConfig> {
117    let chub_dir = project_chub_dir()?;
118    let config_path = chub_dir.join("config.yaml");
119    let raw = fs::read_to_string(&config_path).ok()?;
120    serde_yaml::from_str(&raw).ok()
121}
122
123/// Initialize a `.chub/` directory in the current working directory.
124pub fn init_project(from_deps: bool, monorepo: bool) -> Result<PathBuf> {
125    let cwd = std::env::current_dir().map_err(|e| Error::Config(e.to_string()))?;
126    let chub_dir = cwd.join(".chub");
127
128    if chub_dir.exists() {
129        return Err(Error::Config(format!(
130            ".chub/ directory already exists at {}",
131            cwd.display()
132        )));
133    }
134
135    fs::create_dir_all(&chub_dir)?;
136    fs::create_dir_all(chub_dir.join("annotations"))?;
137    fs::create_dir_all(chub_dir.join("context"))?;
138    fs::create_dir_all(chub_dir.join("profiles"))?;
139
140    // Write default config.yaml
141    let config_content = r#"# Chub project configuration
142# This file is shared with the team via git.
143# It overrides personal settings in ~/.chub/config.yaml.
144
145# sources:
146#   - name: official
147#     url: https://cdn.aichub.org/v1
148#   - name: company
149#     url: https://docs.internal.company.com/chub
150
151# Agent rules for generating CLAUDE.md, .cursorrules, etc.
152# agent_rules:
153#   global:
154#     - "Follow the project coding conventions"
155#   modules: {}
156#   include_pins: true          # list pinned docs so agents know what to fetch
157#   include_context: true       # list project context docs so agents know what to fetch
158#   include_annotation_policy: true  # add standing instructions for agents to write annotations
159#   targets:
160#     - claude.md
161
162# annotation_server:
163#   url: https://annotations.internal.company.com
164#   auto_push: false  # set true to mirror team writes to org tier
165#   cache_ttl_secs: 3600
166"#;
167    fs::write(chub_dir.join("config.yaml"), config_content)?;
168
169    // Write empty pins.yaml
170    fs::write(chub_dir.join("pins.yaml"), "pins: []\n")?;
171
172    // Write base profile
173    let base_profile = r#"name: Base
174description: "Shared rules for all roles"
175rules: []
176context: []
177"#;
178    fs::write(chub_dir.join("profiles").join("base.yaml"), base_profile)?;
179
180    // Write example context doc
181    let example_context = r#"---
182name: Project Architecture
183description: "High-level architecture overview"
184tags: architecture
185---
186
187# Architecture Overview
188
189Describe your project architecture here.
190"#;
191    fs::write(
192        chub_dir.join("context").join("architecture.md"),
193        example_context,
194    )?;
195
196    // Write .gitignore for .chub/
197    // Nothing to ignore by default — everything is git-tracked
198    fs::write(chub_dir.join(".gitignore"), "# .chub/ is git-tracked\n")?;
199
200    if monorepo {
201        // For monorepo, also create auto_profile example in config
202        let monorepo_config = r#"# Chub project configuration (monorepo)
203
204# auto_profile:
205#   - path: "packages/api/**"
206#     profile: backend
207#   - path: "packages/web/**"
208#     profile: frontend
209
210agent_rules:
211  global:
212    - "Follow the project coding conventions"
213  modules: {}
214  include_pins: true
215  include_context: true
216  include_annotation_policy: true
217  targets:
218    - claude.md
219"#;
220        fs::write(chub_dir.join("config.yaml"), monorepo_config)?;
221    }
222
223    if from_deps {
224        // Will be handled by detect module after init
225        // Just create a marker so the caller knows to run detect
226        fs::write(chub_dir.join(".init_from_deps"), "")?;
227    }
228
229    Ok(chub_dir)
230}