package app import ( "fmt" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) { outputName, err := planned.Resolved.OutputName() if err != nil { return "", err } return validateOutputPath(filepath.Join(outputDir, outputName)) } func prepareBatchOutputs(outputDir string, plannedReports []plannedBatchReport) error { for index := range plannedReports { outputPath, err := plannedBatchOutputPath(outputDir, plannedReports[index]) if err != nil { return err } plannedReports[index].OutputPath = outputPath } return nil } func resolveReportOutputPath(workingDir, override, configuredDir string, resolved report.Resolved) (string, error) { outputName, err := resolved.OutputName() if err != nil { return "", err } if override != "" { return resolveOutputPath(workingDir, override, outputName) } outputDir, err := resolveOutputDir(workingDir, configuredDir) if err != nil { return "", err } return validateOutputPath(filepath.Join(outputDir, outputName)) } func resolveOutputDirWithConfigured(workingDir, override, configuredDir string) (string, error) { directory := configuredDir if override != "" { directory = override } return resolveOutputDir(workingDir, directory) } func resolveComparisonOutputDirectory(workingDir, override, configuredDir, reportOutputName string) (string, error) { workingDir, err := validateWorkingDir(workingDir) if err != nil { return "", err } if override != "" { return resolveComparisonDirectoryPath(workingDir, override) } outputDir, err := resolveOutputDir(workingDir, configuredDir) if err != nil { return "", err } name, err := comparison.DefaultDirectoryName(reportOutputName) if err != nil { return "", err } return filepath.Join(outputDir, name), nil } func resolveComparisonDirectoryPath(workingDir, directory string) (string, error) { if strings.TrimSpace(directory) == "" { return "", fmt.Errorf("comparison output directory is required") } if !filepath.IsAbs(directory) { directory = filepath.Join(workingDir, directory) } return filepath.Clean(directory), nil } func resolveOutputDir(workingDir, override string) (string, error) { workingDir, err := validateWorkingDir(workingDir) if err != nil { return "", err } if override == "" { return workingDir, nil } if strings.TrimSpace(override) == "" { return "", fmt.Errorf("output directory is required") } directory := override if !filepath.IsAbs(directory) { directory = filepath.Join(workingDir, directory) } directory = filepath.Clean(directory) if err := preflightOutputDirectory(directory); err != nil { return "", err } return directory, nil } func preflightOutputDirectory(directory string) error { info, err := os.Stat(directory) if err == nil { if !info.IsDir() { return fmt.Errorf("output directory %q is not a directory", directory) } return nil } if !os.IsNotExist(err) { return fmt.Errorf("inspect output directory %q: %w", directory, err) } // A missing directory is valid, but os.Stat also reports ErrNotExist for a // dangling symlink. Walk to the first existing component so invalid links // fail preflight instead of being discovered only during publication. for component := directory; ; component = filepath.Dir(component) { componentInfo, componentErr := os.Lstat(component) if componentErr == nil { if componentInfo.Mode()&os.ModeSymlink != 0 { targetInfo, targetErr := os.Stat(component) if targetErr != nil { return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, targetErr) } if !targetInfo.IsDir() { return fmt.Errorf("output directory %q has non-directory path component %q", directory, component) } return nil } if !componentInfo.IsDir() { return fmt.Errorf("output directory %q has non-directory path component %q", directory, component) } return nil } if !os.IsNotExist(componentErr) { return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, componentErr) } if filepath.Dir(component) == component { return fmt.Errorf("inspect output directory %q: no existing directory ancestor", directory) } } } func resolveOutputPath(workingDir, override, defaultName string) (string, error) { workingDir, err := validateWorkingDir(workingDir) if err != nil { return "", err } path := override if path == "" { path = defaultName } if strings.TrimSpace(path) == "" { return "", fmt.Errorf("final output path is required") } if !filepath.IsAbs(path) { path = filepath.Join(workingDir, path) } return validateOutputPath(path) } func validateWorkingDir(workingDir string) (string, error) { if strings.TrimSpace(workingDir) == "" { return "", fmt.Errorf("working directory is required") } if !filepath.IsAbs(workingDir) { return "", fmt.Errorf("working directory %q must be absolute", workingDir) } return filepath.Clean(workingDir), nil } func validateOutputPath(path string) (string, error) { if strings.TrimSpace(path) == "" { return "", fmt.Errorf("final output path is required") } path = filepath.Clean(path) if !filepath.IsAbs(path) { return "", fmt.Errorf("final output path %q must be absolute", path) } if filepath.Dir(path) == path { return "", fmt.Errorf("final output path %q must not be a filesystem root", path) } if err := fileutil.ValidateAtomicPath(path); err != nil { return "", fmt.Errorf("validate final output path %q: %w", path, err) } return path, nil }