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/// Search upward from CWD (or a given path) for a `.chub/` directory.
10pub fn find_project_root(start: Option<&Path>) -> Option<PathBuf> {
11    let start = start
12        .map(|p| p.to_path_buf())
13        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
14
15    let mut current = start.as_path();
16    loop {
17        let candidate = current.join(".chub");
18        if candidate.is_dir() {
19            return Some(current.to_path_buf());
20        }
21        current = current.parent()?;
22    }
23}
24
25/// Get the `.chub/` directory for the current project (if any).
26pub fn project_chub_dir() -> Option<PathBuf> {
27    find_project_root(None).map(|root| root.join(".chub"))
28}
29
30/// Project-level config that extends the global config.
31#[derive(Debug, Clone, Default, Deserialize)]
32pub struct ProjectFileConfig {
33    #[serde(default)]
34    pub sources: Option<Vec<SourceConfig>>,
35    #[serde(default)]
36    pub cdn_url: Option<String>,
37    #[serde(default)]
38    pub output_dir: Option<String>,
39    #[serde(default)]
40    pub refresh_interval: Option<u64>,
41    #[serde(default)]
42    pub output_format: Option<String>,
43    #[serde(default)]
44    pub source: Option<String>,
45    #[serde(default)]
46    pub telemetry: Option<bool>,
47    #[serde(default)]
48    pub feedback: Option<bool>,
49    #[serde(default)]
50    pub telemetry_url: Option<String>,
51    #[serde(default)]
52    pub agent_rules: Option<AgentRules>,
53    #[serde(default)]
54    pub auto_profile: Option<Vec<AutoProfileEntry>>,
55}
56
57/// Agent rules configuration for generating CLAUDE.md, .cursorrules, etc.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct AgentRules {
60    #[serde(default)]
61    pub global: Vec<String>,
62    #[serde(default)]
63    pub modules: std::collections::HashMap<String, ModuleRules>,
64    #[serde(default)]
65    pub include_pins: bool,
66    #[serde(default)]
67    pub include_context: bool,
68    #[serde(default)]
69    pub targets: Vec<String>,
70}
71
72/// Rules scoped to a module/path pattern.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct ModuleRules {
75    pub path: String,
76    #[serde(default)]
77    pub rules: Vec<String>,
78}
79
80/// Auto-profile entry: maps a path glob to a profile name.
81#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct AutoProfileEntry {
83    pub path: String,
84    pub profile: String,
85}
86
87/// Load the project-level config from `.chub/config.yaml`.
88pub fn load_project_config() -> Option<ProjectFileConfig> {
89    let chub_dir = project_chub_dir()?;
90    let config_path = chub_dir.join("config.yaml");
91    let raw = fs::read_to_string(&config_path).ok()?;
92    serde_yaml::from_str(&raw).ok()
93}
94
95/// Initialize a `.chub/` directory in the current working directory.
96pub fn init_project(from_deps: bool, monorepo: bool) -> Result<PathBuf> {
97    let cwd = std::env::current_dir().map_err(|e| Error::Config(e.to_string()))?;
98    let chub_dir = cwd.join(".chub");
99
100    if chub_dir.exists() {
101        return Err(Error::Config(format!(
102            ".chub/ directory already exists at {}",
103            cwd.display()
104        )));
105    }
106
107    fs::create_dir_all(&chub_dir)?;
108    fs::create_dir_all(chub_dir.join("annotations"))?;
109    fs::create_dir_all(chub_dir.join("context"))?;
110    fs::create_dir_all(chub_dir.join("profiles"))?;
111
112    // Write default config.yaml
113    let config_content = r#"# Chub project configuration
114# This file is shared with the team via git.
115# It overrides personal settings in ~/.chub/config.yaml.
116
117# sources:
118#   - name: official
119#     url: https://cdn.aichub.org/v1
120#   - name: company
121#     url: https://docs.internal.company.com/chub
122
123# Agent rules for generating CLAUDE.md, .cursorrules, etc.
124# agent_rules:
125#   global:
126#     - "Follow the project coding conventions"
127#   modules: {}
128#   include_pins: true
129#   include_context: true
130#   targets:
131#     - claude.md
132"#;
133    fs::write(chub_dir.join("config.yaml"), config_content)?;
134
135    // Write empty pins.yaml
136    fs::write(chub_dir.join("pins.yaml"), "pins: []\n")?;
137
138    // Write base profile
139    let base_profile = r#"name: Base
140description: "Shared rules for all roles"
141rules: []
142context: []
143"#;
144    fs::write(chub_dir.join("profiles").join("base.yaml"), base_profile)?;
145
146    // Write example context doc
147    let example_context = r#"---
148name: Project Architecture
149description: "High-level architecture overview"
150tags: architecture
151---
152
153# Architecture Overview
154
155Describe your project architecture here.
156"#;
157    fs::write(
158        chub_dir.join("context").join("architecture.md"),
159        example_context,
160    )?;
161
162    // Write .gitignore for .chub/
163    // Nothing to ignore by default — everything is git-tracked
164    fs::write(chub_dir.join(".gitignore"), "# .chub/ is git-tracked\n")?;
165
166    if monorepo {
167        // For monorepo, also create auto_profile example in config
168        let monorepo_config = r#"# Chub project configuration (monorepo)
169
170# auto_profile:
171#   - path: "packages/api/**"
172#     profile: backend
173#   - path: "packages/web/**"
174#     profile: frontend
175
176agent_rules:
177  global:
178    - "Follow the project coding conventions"
179  modules: {}
180  include_pins: true
181  include_context: true
182  targets:
183    - claude.md
184"#;
185        fs::write(chub_dir.join("config.yaml"), monorepo_config)?;
186    }
187
188    if from_deps {
189        // Will be handled by detect module after init
190        // Just create a marker so the caller knows to run detect
191        fs::write(chub_dir.join(".init_from_deps"), "")?;
192    }
193
194    Ok(chub_dir)
195}