Skip to main content

vtcode_core/
turn_metadata.rs

1//! Turn metadata for LLM requests
2//!
3//! This module provides utilities for building turn metadata headers that are
4//! sent with LLM requests, similar to OpenAI Codex PR #10145. The metadata
5//! includes workspace information like git remote URLs and commit hash.
6
7use crate::git_info::{self};
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::BTreeMap;
12use std::path::Path;
13use std::time::Duration;
14
15/// Workspace information included in turn metadata
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct WorkspaceInfo {
18    /// Git remote URLs keyed by remote name
19    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
20    pub remote_urls: BTreeMap<String, String>,
21    /// HEAD commit hash
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub commit_hash: Option<String>,
24    /// Repository root path
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub repo_root: Option<String>,
27}
28
29/// Turn metadata structure sent as X-Turn-Metadata header
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct TurnMetadata {
32    /// Workspace information
33    pub workspace: WorkspaceInfo,
34}
35
36/// Build the turn metadata header value for the given working directory.
37///
38/// This function collects git information from the workspace and formats it
39/// as a JSON string suitable for use as the X-Turn-Metadata header value.
40///
41/// # Arguments
42/// * `cwd` - The working directory to collect metadata from
43///
44/// # Returns
45/// A JSON string containing the turn metadata, or an empty string if
46/// metadata collection fails or the directory is not in a git repository.
47///
48/// # Example
49/// ```
50/// use std::path::Path;
51/// use vtcode_core::turn_metadata::build_turn_metadata_header;
52///
53/// let metadata = build_turn_metadata_header(Path::new(".")).unwrap();
54/// // metadata will be a JSON string like:
55/// // {"workspace":{"remote_urls":{"origin":"https://github.com/user/repo.git"},"commit_hash":"abc1234"}}
56/// ```
57pub fn build_turn_metadata_header(cwd: &Path) -> Result<String> {
58    if !git_info::is_git_repo(cwd) {
59        return Ok(String::new());
60    }
61
62    let git_info = git_info::collect_git_info(cwd)?;
63
64    let metadata = TurnMetadata {
65        workspace: WorkspaceInfo {
66            remote_urls: git_info.remotes,
67            commit_hash: git_info.head_commit,
68            repo_root: git_info.repo_root,
69        },
70    };
71
72    // Serialize to compact JSON
73    let json = serde_json::to_string(&metadata)?;
74    Ok(json)
75}
76
77/// Build turn metadata as a serde_json::Value for direct use in LLM requests.
78///
79/// # Arguments
80/// * `cwd` - The working directory to collect metadata from
81///
82/// # Returns
83/// A serde_json::Value containing the turn metadata, or Null if
84/// metadata collection fails or the directory is not in a git repository.
85pub fn build_turn_metadata_value(cwd: &Path) -> Result<Value> {
86    if !git_info::is_git_repo(cwd) {
87        return Ok(Value::Null);
88    }
89
90    let git_info = git_info::collect_git_info(cwd)?;
91
92    let metadata = TurnMetadata {
93        workspace: WorkspaceInfo {
94            remote_urls: git_info.remotes,
95            commit_hash: git_info.head_commit,
96            repo_root: git_info.repo_root,
97        },
98    };
99
100    let value = serde_json::to_value(metadata)?;
101    Ok(value)
102}
103
104/// Build turn metadata with a timeout to avoid blocking turn execution.
105///
106/// Returns `Ok(None)` when metadata is unavailable or times out.
107pub async fn build_turn_metadata_value_with_timeout(
108    cwd: &Path,
109    timeout: Duration,
110) -> Result<Option<Value>> {
111    if !git_info::is_git_repo(cwd) {
112        return Ok(None);
113    }
114
115    let cwd = cwd.to_path_buf();
116    let handle = tokio::task::spawn_blocking(move || build_turn_metadata_value(&cwd));
117    match tokio::time::timeout(timeout, handle).await {
118        Ok(join_result) => {
119            let value = join_result.context("Turn metadata task failed")??;
120            if value.is_null() {
121                Ok(None)
122            } else {
123                Ok(Some(value))
124            }
125        }
126        Err(_) => {
127            tracing::debug!("Turn metadata collection timed out");
128            Ok(None)
129        }
130    }
131}
132
133/// Get the header name for turn metadata.
134/// This is the header key used when sending metadata to LLM providers.
135pub const TURN_METADATA_HEADER: &str = "X-Turn-Metadata";
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::path::PathBuf;
141
142    #[test]
143    fn test_build_turn_metadata_header() {
144        let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
145        let metadata = build_turn_metadata_header(&repo_root).unwrap();
146
147        // Should produce valid JSON
148        let parsed: Value = serde_json::from_str(&metadata).unwrap();
149        assert!(parsed.get("workspace").is_some());
150    }
151
152    #[test]
153    fn test_build_turn_metadata_value() {
154        let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
155        let value = build_turn_metadata_value(&repo_root).unwrap();
156
157        assert!(!value.is_null());
158        assert!(value.get("workspace").is_some());
159    }
160
161    #[test]
162    fn test_turn_metadata_header_constant() {
163        assert_eq!(TURN_METADATA_HEADER, "X-Turn-Metadata");
164    }
165
166    #[test]
167    fn test_workspace_info_serialization() {
168        let mut remotes = BTreeMap::new();
169        remotes.insert(
170            "origin".to_string(),
171            "https://github.com/user/repo.git".to_string(),
172        );
173
174        let workspace = WorkspaceInfo {
175            remote_urls: remotes,
176            commit_hash: Some("abc1234".to_string()),
177            repo_root: Some("/path/to/repo".to_string()),
178        };
179
180        let json = serde_json::to_string(&workspace).unwrap();
181        assert!(json.contains("origin"));
182        assert!(json.contains("abc1234"));
183        assert!(json.contains("/path/to/repo"));
184    }
185
186    #[test]
187    fn test_empty_remotes_skipped() {
188        let workspace = WorkspaceInfo {
189            remote_urls: BTreeMap::new(),
190            commit_hash: Some("abc1234".to_string()),
191            repo_root: None,
192        };
193
194        let json = serde_json::to_string(&workspace).unwrap();
195        // Empty remotes should be skipped due to skip_serializing_if
196        assert!(!json.contains("remote_urls"));
197        assert!(json.contains("commit_hash"));
198    }
199}